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 if let Some(pid) = process_id {
10813 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10814 }
10815 #[cfg(unix)]
10816 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10817 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10818 }
10819 tracing::info!(
10820 node_id = %self.node_id,
10821 workspace_id = %address.workspace_id,
10822 session = ?address.session,
10823 pid = ?process_id,
10824 "session process is running",
10825 );
10826 }
10827 ControlEventKind::Exited { exit_code, forced } => {
10828 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10829 #[cfg(unix)]
10830 if let Some(proxy) = self.signer_proxy.get().cloned() {
10831 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10832 if proxy.has_session(&key) {
10833 std::thread::spawn(move || proxy.release(&key));
10835 }
10836 }
10837 tracing::info!(
10838 node_id = %self.node_id,
10839 workspace_id = %address.workspace_id,
10840 session = ?address.session,
10841 exit_code = ?exit_code,
10842 forced = forced,
10843 "session process exited",
10844 );
10845 }
10846 ControlEventKind::Failed { message } => {
10847 tracing::warn!(
10848 node_id = %self.node_id,
10849 workspace_id = %address.workspace_id,
10850 session = ?address.session,
10851 cause = %message,
10852 "session failed",
10853 );
10854 }
10855 ControlEventKind::CommandRejected { message } => {
10856 tracing::warn!(
10857 node_id = %self.node_id,
10858 workspace_id = %address.workspace_id,
10859 session = ?address.session,
10860 cause = %message,
10861 "session command rejected",
10862 );
10863 }
10864 ControlEventKind::Removed => {
10865 tracing::info!(
10866 node_id = %self.node_id,
10867 workspace_id = %address.workspace_id,
10868 session = ?address.session,
10869 "session removed",
10870 );
10871 }
10872 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10873 tracing::info!(
10874 node_id = %self.node_id,
10875 workspace_id = %address.workspace_id,
10876 session = ?address.session,
10877 provider_session = ?provider_session,
10878 "session resume authorized",
10879 );
10880 }
10881 ControlEventKind::Resumed { session: provider_session, process_id } => {
10882 tracing::info!(
10883 node_id = %self.node_id,
10884 workspace_id = %address.workspace_id,
10885 session = ?address.session,
10886 provider_session = ?provider_session,
10887 pid = ?process_id,
10888 "session resumed",
10889 );
10890 }
10891 ControlEventKind::ResumeDenied { reason } => {
10892 tracing::warn!(
10893 node_id = %self.node_id,
10894 workspace_id = %address.workspace_id,
10895 session = ?address.session,
10896 cause = %reason,
10897 "session resume denied",
10898 );
10899 }
10900 ControlEventKind::ResumeFailed { message } => {
10901 tracing::warn!(
10902 node_id = %self.node_id,
10903 workspace_id = %address.workspace_id,
10904 session = ?address.session,
10905 cause = %message,
10906 "session resume failed",
10907 );
10908 }
10909 _ => {}
10910 }
10911 self.apply_pending_acp_options(&address, &event);
10912 self.apply_acp_auto_permit(&address, &event);
10913 let stream_chunk = agent_stream_chunk(&event);
10914 self.publish(NodeEvent::Control {
10915 address: address.clone(),
10916 event,
10917 });
10918 if let Some(chunk) = stream_chunk {
10919 self.publish_agent_stream_chunk(address.clone(), chunk);
10920 }
10921 if revokes_harness_mcp {
10922 SessionLifecycleHook::on_session_ended(self, &address);
10923 }
10924 }
10925
10926 fn reconcile_managed_record(
10927 &self,
10928 record_id: &SessionRecordId,
10929 address: &SessionAddress,
10930 event: &ControlEvent,
10931 identity_admitted: bool,
10932 resume_command_rejected: bool,
10933 ) {
10934 let _transaction = self
10935 .state_transaction
10936 .lock()
10937 .unwrap_or_else(|poisoned| poisoned.into_inner());
10938 let observed_identity = match &event.event {
10939 ControlEventKind::ProviderEvent {
10940 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10941 ..
10942 } if identity_admitted => Some(identity.clone()),
10943 _ => None,
10944 };
10945 let identity_was_observed = observed_identity.is_some();
10946 let replacement_id = observed_identity
10947 .as_ref()
10948 .and_then(|_| self.allocate_record_id().ok());
10949 let mut upserts = Vec::new();
10950 let mut removals = Vec::new();
10951 let mut rebind = None;
10952 let original_records;
10953 let original_materializations = self
10954 .materializations
10955 .lock()
10956 .unwrap_or_else(|poisoned| poisoned.into_inner())
10957 .clone();
10958 {
10959 let mut records = self
10960 .session_records
10961 .lock()
10962 .unwrap_or_else(|poisoned| poisoned.into_inner());
10963 original_records = records.records.clone();
10964 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10965 return;
10966 };
10967 if let Some(identity) = observed_identity {
10968 let existing_id = records
10969 .records
10970 .values()
10971 .find(|record| {
10972 record.record_id != *record_id
10973 && record.provider_session.as_ref().is_some_and(|record_identity| {
10974 session_registry::same_provider_session(
10975 &record.provider,
10976 record_identity,
10977 ¤t_snapshot.provider,
10978 &identity,
10979 )
10980 })
10981 })
10982 .map(|record| record.record_id.clone());
10983 if let Some(existing_id) = existing_id {
10984 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10985 record.mode == current_snapshot.mode
10986 && record.workspace_id == current_snapshot.workspace_id
10987 && record.environment_profile == current_snapshot.environment_profile
10988 && record.bundle == current_snapshot.bundle
10989 && record.context_id == current_snapshot.context_id
10990 && record.context == current_snapshot.context
10991 && record
10992 .canonical_root
10993 .as_utf8()
10994 .zip(current_snapshot.canonical_root.as_utf8())
10995 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10996 });
10997 if !scope_matches {
10998 if let Some(current) = records.records.get_mut(record_id) {
10999 current.state = ManagedSessionState::Unavailable;
11000 current.last_error =
11001 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11002 current.updated_at_unix_ms = unix_time_ms();
11003 upserts.push(current.clone());
11004 }
11005 } else {
11006 let existing_is_active = records
11007 .records
11008 .get(&existing_id)
11009 .is_some_and(|record| record.active_session.is_some());
11010 if existing_is_active {
11011 if let Some(current) = records.records.get_mut(record_id) {
11012 current.state = ManagedSessionState::Unavailable;
11013 current.last_error =
11014 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11015 current.updated_at_unix_ms = unix_time_ms();
11016 upserts.push(current.clone());
11017 }
11018 } else {
11019 if current_snapshot.provider_session.is_some() {
11020 if let Some(current) = records.records.get_mut(record_id) {
11021 current.active_session = None;
11022 current.state = ManagedSessionState::Dormant;
11023 current.updated_at_unix_ms = unix_time_ms();
11024 upserts.push(current.clone());
11025 }
11026 } else {
11027 records.records.remove(record_id);
11028 removals.push(record_id.clone());
11029 }
11030 if let Some(existing) = records.records.get_mut(&existing_id) {
11031 existing.provider_session = Some(identity.clone());
11032 existing.active_session = Some(address.clone());
11033 existing.state = ManagedSessionState::Live;
11034 existing.updated_at_unix_ms = unix_time_ms();
11035 existing.last_error = None;
11036 upserts.push(existing.clone());
11037 }
11038 rebind = Some(existing_id);
11039 }
11040 }
11041 } else if current_snapshot
11042 .provider_session
11043 .as_ref()
11044 .is_some_and(|current| {
11045 !session_registry::same_provider_session(
11046 ¤t_snapshot.provider,
11047 current,
11048 ¤t_snapshot.provider,
11049 &identity,
11050 )
11051 })
11052 {
11053 if let Some(current) = records.records.get_mut(record_id) {
11054 current.active_session = None;
11055 if current.bundle.is_some() || current.context.is_some() {
11056 let had_bundle = current.bundle.is_some();
11057 current.provider_session = None;
11058 current.bundle = None;
11059 current.context_id = None;
11060 current.context = None;
11061 current.state = ManagedSessionState::Unavailable;
11062 current.last_error = Some(if had_bundle {
11063 "bundle-unavailable".to_owned()
11064 } else {
11065 "context-unavailable".to_owned()
11066 });
11067 } else {
11068 current.state = ManagedSessionState::Dormant;
11069 }
11070 current.updated_at_unix_ms = unix_time_ms();
11071 upserts.push(current.clone());
11072 }
11073 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11074 if let Some(current) = records.records.get_mut(record_id) {
11075 current.state = ManagedSessionState::Unavailable;
11076 current.last_error =
11077 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11078 current.updated_at_unix_ms = unix_time_ms();
11079 upserts.push(current.clone());
11080 }
11081 } else if let Some(new_record_id) = replacement_id {
11082 let now = unix_time_ms();
11083 let replacement = ManagedSessionRecord {
11084 record_id: new_record_id.clone(),
11085 display_name: format!(
11086 "{} #{}",
11087 current_snapshot.provider.as_str(),
11088 records
11089 .records
11090 .values()
11091 .filter(|record| {
11092 record.provider == current_snapshot.provider
11093 })
11094 .count()
11095 .saturating_add(1),
11096 ),
11097 provider: current_snapshot.provider.clone(),
11098 mode: current_snapshot.mode,
11099 state: ManagedSessionState::Live,
11100 workspace_id: current_snapshot.workspace_id,
11101 canonical_root: current_snapshot.canonical_root,
11102 provider_session: Some(identity),
11103 active_session: Some(address.clone()),
11104 environment_profile: current_snapshot.environment_profile,
11105 bundle: current_snapshot.bundle,
11106 context_id: current_snapshot.context_id,
11107 context: current_snapshot.context,
11108 exported_context: None,
11114 task_binding: current_snapshot.task_binding,
11115 created_at_unix_ms: now,
11116 updated_at_unix_ms: now,
11117 last_error: None,
11118 };
11119 records
11120 .records
11121 .insert(new_record_id.clone(), replacement.clone());
11122 upserts.push(replacement);
11123 rebind = Some(new_record_id);
11124 } else if let Some(current) = records.records.get_mut(record_id) {
11125 current.state = ManagedSessionState::Unavailable;
11126 current.last_error =
11127 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11128 upserts.push(current.clone());
11129 }
11130 } else if let Some(current) = records.records.get_mut(record_id) {
11131 current.provider_session = Some(identity);
11132 current.active_session = Some(address.clone());
11133 current.state = ManagedSessionState::Live;
11134 current.updated_at_unix_ms = unix_time_ms();
11135 current.last_error = None;
11136 upserts.push(current.clone());
11137 }
11138 } else if let Some(current) = records.records.get_mut(record_id) {
11139 if resume_command_rejected {
11140 current.active_session = None;
11141 current.state = detached_record_state(current);
11142 current.updated_at_unix_ms = unix_time_ms();
11143 current.last_error =
11144 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11145 upserts.push(current.clone());
11146 } else {
11147 match &event.event {
11148 ControlEventKind::Running { .. }
11149 | ControlEventKind::ResumeAuthorized { .. }
11150 | ControlEventKind::Resumed { .. } => {
11151 current.active_session = Some(address.clone());
11152 current.state = active_record_state(
11153 identity_admitted,
11154 current.provider_session.is_some(),
11155 );
11156 current.updated_at_unix_ms = unix_time_ms();
11157 current.last_error = None;
11158 upserts.push(current.clone());
11159 }
11160 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11161 current.active_session = None;
11162 current.state = detached_record_state(current);
11163 current.updated_at_unix_ms = unix_time_ms();
11164 upserts.push(current.clone());
11165 }
11166 ControlEventKind::Failed { message }
11167 | ControlEventKind::ResumeFailed { message } => {
11168 current.active_session = None;
11169 current.state = detached_record_state(current);
11170 current.updated_at_unix_ms = unix_time_ms();
11171 current.last_error = Some(
11172 session_registry::sanitized_record_error_summary(message),
11173 );
11174 upserts.push(current.clone());
11175 }
11176 ControlEventKind::ResumeDenied { reason } => {
11177 current.active_session = None;
11178 current.state = detached_record_state(current);
11179 current.updated_at_unix_ms = unix_time_ms();
11180 current.last_error = Some(
11181 session_registry::sanitized_record_error_summary(reason),
11182 );
11183 upserts.push(current.clone());
11184 }
11185 _ => {}
11186 }
11187 }
11188 }
11189 }
11190 let materialization_owner_record = identity_was_observed.then(|| {
11191 rebind.clone().unwrap_or_else(|| record_id.clone())
11192 }).and_then(|candidate| {
11193 self.session_records
11194 .lock()
11195 .unwrap_or_else(|poisoned| poisoned.into_inner())
11196 .records
11197 .get(&candidate)
11198 .filter(|record| {
11199 record.provider_session.is_some()
11200 && record.active_session.as_ref() == Some(address)
11201 })
11202 .map(|_| candidate)
11203 });
11204 if let Some(owner_record_id) = materialization_owner_record {
11205 let materialization_id = self
11206 .session_bindings
11207 .lock()
11208 .unwrap_or_else(|poisoned| poisoned.into_inner())
11209 .get(&address.session.instance_id)
11210 .and_then(|binding| binding.materialization_id.clone());
11211 if let Some(materialization_id) = materialization_id {
11212 let mut transfer_failed = false;
11213 {
11214 let mut materializations = self
11215 .materializations
11216 .lock()
11217 .unwrap_or_else(|poisoned| poisoned.into_inner());
11218 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11219 match ownership.owner() {
11220 MaterializationOwner::Session {
11221 incarnation_id,
11222 instance_id,
11223 generation,
11224 } if *incarnation_id == self.incarnation_id
11225 && *instance_id == address.session.instance_id
11226 && *generation == address.session.generation =>
11227 {
11228 transfer_failed = ownership
11229 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11230 .is_err();
11231 }
11232 MaterializationOwner::Record { record_id }
11233 if record_id == &owner_record_id => {}
11234 MaterializationOwner::Record { record_id } => {
11235 let expected = record_id.clone();
11236 transfer_failed = ownership
11237 .transfer_record_owner(
11238 &expected,
11239 owner_record_id.clone(),
11240 unix_time_ms(),
11241 )
11242 .is_err();
11243 }
11244 _ => transfer_failed = true,
11245 }
11246 if transfer_failed {
11247 let _ = ownership.mark_recovery_required(unix_time_ms());
11248 }
11249 } else {
11250 transfer_failed = true;
11251 }
11252 }
11253 if transfer_failed {
11254 if let Some(record) = self
11255 .session_records
11256 .lock()
11257 .unwrap_or_else(|poisoned| poisoned.into_inner())
11258 .records
11259 .get_mut(&owner_record_id)
11260 {
11261 record.state = ManagedSessionState::Unavailable;
11262 record.last_error = Some("environment-profile-unavailable".to_owned());
11263 record.updated_at_unix_ms = unix_time_ms();
11264 upserts.push(record.clone());
11265 }
11266 }
11267 }
11268 }
11269 if let Some(new_record_id) = rebind.clone() {
11270 if let Some(binding) = self
11271 .session_bindings
11272 .lock()
11273 .unwrap_or_else(|poisoned| poisoned.into_inner())
11274 .get_mut(&address.session.instance_id)
11275 {
11276 binding.record_id = Some(new_record_id);
11277 }
11278 }
11279 if upserts.is_empty() && removals.is_empty() {
11280 return;
11281 }
11282 if self.persist_state_locked().is_err() {
11283 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11284 let mut records = self
11285 .session_records
11286 .lock()
11287 .unwrap_or_else(|poisoned| poisoned.into_inner());
11288 records.records = original_records;
11289 *self
11290 .materializations
11291 .lock()
11292 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11293 if let Some(current) = records.records.get_mut(record_id) {
11294 current.state = ManagedSessionState::Unavailable;
11295 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11296 current.updated_at_unix_ms = unix_time_ms();
11297 upserts = vec![current.clone()];
11298 } else {
11299 upserts.clear();
11300 }
11301 removals.clear();
11302 drop(records);
11303 if let Some(binding) = self
11304 .session_bindings
11305 .lock()
11306 .unwrap_or_else(|poisoned| poisoned.into_inner())
11307 .get_mut(&address.session.instance_id)
11308 {
11309 binding.record_id = Some(record_id.clone());
11310 }
11311 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11312 }
11313 for record in upserts {
11314 self.publish_record(record);
11315 }
11316 for record_id in removals {
11317 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11318 }
11319 }
11320
11321 fn snapshot(&self) -> NodeSnapshot {
11322 let control = self.handle.snapshot();
11323 let bindings = self
11324 .session_bindings
11325 .lock()
11326 .unwrap_or_else(|poisoned| poisoned.into_inner())
11327 .clone();
11328 let workspace_roots = self
11329 .workspaces
11330 .read()
11331 .unwrap_or_else(|poisoned| poisoned.into_inner())
11332 .clone();
11333 let environment_profiles = self
11334 .environment_profiles
11335 .read()
11336 .unwrap_or_else(|poisoned| poisoned.into_inner());
11337 let mut workspaces = workspace_roots
11338 .iter()
11339 .map(|(workspace_id, canonical_root)| {
11340 (
11341 workspace_id.clone(),
11342 WorkspaceSnapshot {
11343 workspace_id: workspace_id.clone(),
11344 canonical_root: opaque_windows_path(canonical_root.clone()),
11345 sessions: Vec::new(),
11346 worktree_service_mode: Some(
11347 self.worktree_service_modes
11348 .get(workspace_id)
11349 .copied()
11350 .unwrap_or(WorktreeServiceMode::Manual),
11351 ),
11352 managed_worktree_profiles: Some(WorktreeProfileInventory {
11353 profiles: self
11354 .managed_worktree_profiles
11355 .get(workspace_id)
11356 .into_iter()
11357 .flat_map(BTreeMap::values)
11358 .map(|profile| ManagedWorktreeProfileSummary {
11359 id: profile.profile_id().clone(),
11360 revision: profile.revision().clone(),
11361 retention: profile.retention(),
11362 })
11363 .collect(),
11364 }),
11365 },
11366 )
11367 })
11368 .collect::<BTreeMap<_, _>>();
11369 let mut agent_progress = Vec::new();
11370 for session in &control.sessions {
11371 let Some(binding) = bindings.get(&session.instance_id) else {
11372 continue;
11373 };
11374 if binding.generation != session.generation {
11375 continue;
11376 }
11377 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11378 let address = SessionAddress {
11379 workspace_id: binding.workspace_id.clone(),
11380 session: SessionKey {
11381 instance_id: session.instance_id,
11382 generation: session.generation,
11383 },
11384 };
11385 if let Some(progress) =
11386 agent_progress_from_provider_snapshot(address, &session.provider)
11387 {
11388 agent_progress.push(progress);
11389 }
11390 workspace.sessions.push(session.clone());
11391 }
11392 }
11393 NodeSnapshot {
11394 node_id: self.node_id.clone(),
11395 enabled_providers: self.enabled_providers.clone(),
11396 provider_runtime_statuses: {
11397 let updates = self
11398 .provider_runtime_status_updates
11399 .read()
11400 .unwrap_or_else(|poisoned| poisoned.into_inner());
11401 ProviderRuntimeStatuses::new(
11402 self.provider_runtime_statuses
11403 .iter()
11404 .filter(|status| !updates.contains_key(status.provider()))
11405 .cloned()
11406 .chain(updates.values().cloned()),
11407 )
11408 .expect("refreshed provider runtime inventory remains bounded and unique")
11409 },
11410 workspaces: workspaces.into_values().collect(),
11411 session_records: self
11412 .session_records
11413 .lock()
11414 .unwrap_or_else(|poisoned| poisoned.into_inner())
11415 .records
11416 .values()
11417 .cloned()
11418 .collect(),
11419 managed_worktrees: self.managed_worktrees
11420 .lock()
11421 .unwrap_or_else(|poisoned| poisoned.into_inner())
11422 .snapshots(),
11423 launch_inventory: Some(LaunchInventory {
11424 spawn_profiles: Some(
11425 self.spawn_profiles
11426 .iter()
11427 .filter_map(|profile| {
11428 resolved_spawn_profile_summary(profile, &environment_profiles)
11429 })
11430 .collect(),
11431 ),
11432 bundles: Some(
11433 self.bundle_catalog
11434 .read()
11435 .unwrap_or_else(|poisoned| poisoned.into_inner())
11436 .iter()
11437 .map(NodeBundle::receipt)
11438 .collect(),
11439 ),
11440 network_allowlists: {
11443 let catalog = self
11444 .network_allowlist_catalog
11445 .read()
11446 .unwrap_or_else(|poisoned| poisoned.into_inner());
11447 if catalog.is_empty() {
11448 None
11449 } else {
11450 Some(catalog.ids().cloned().collect())
11451 }
11452 },
11453 }),
11454 agent_progress,
11455 }
11456 }
11457
11458 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11459 assert!(
11460 !matches!(
11461 &event,
11462 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11463 ),
11464 "terminal frame and agent stream events must use their own dedicated live channel",
11465 );
11466 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11467 let sequence = history
11468 .last_sequence
11469 .checked_add(1)
11470 .expect("node event sequence exhausted");
11471 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11472 if let Some(evicted) = history.events.pop_front() {
11473 history.replay_floor_sequence = evicted
11474 .sequence
11475 .checked_add(1)
11476 .expect("node replay floor sequence exhausted");
11477 history.removed_record_providers.remove(&evicted.sequence);
11478 }
11479 }
11480 match &event {
11481 NodeEvent::HarnessMcpReadCall { .. } => {
11482 panic!("transient harness MCP calls must not enter durable event history")
11483 }
11484 NodeEvent::SessionRecordUpserted { record } => {
11485 history
11486 .record_providers
11487 .insert(record.record_id.clone(), record.provider.clone());
11488 }
11489 NodeEvent::SessionRecordRemoved { record_id } => {
11490 if let Some(provider) = history.record_providers.remove(record_id) {
11491 history.removed_record_providers.insert(sequence, provider);
11492 }
11493 }
11494 NodeEvent::ManagedWorktreeUpserted { .. }
11495 | NodeEvent::ManagedWorktreeRemoved { .. }
11496 | NodeEvent::Control { .. }
11497 | NodeEvent::ControllerChanged { .. }
11498 | NodeEvent::SessionRecordHistorySummarized { .. }
11499 | NodeEvent::WorkspaceAdded { .. }
11500 | NodeEvent::WorkspaceRemoved { .. }
11501 | NodeEvent::ResyncRequired { .. }
11502 | NodeEvent::AgentStream { .. }
11503 | NodeEvent::TerminalFrame { .. } => {}
11504 }
11505 let envelope = NodeEventEnvelope { sequence, event };
11506 history.events.push_back(envelope.clone());
11507 history.last_sequence = sequence;
11508 let _ = self.event_tx.send(envelope.clone());
11509 drop(history);
11510 envelope
11511 }
11512
11513 fn publish_transient(&self, event: NodeEvent) -> bool {
11524 assert!(event.requires_harness_mcp_proxy_capability());
11525 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11526 let envelope = NodeEventEnvelope { sequence: 0, event };
11527 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11528 Ok(_) => true,
11529 Err(broadcast::error::SendError(undelivered)) => {
11530 if let Some(NodeEventEnvelope {
11531 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11532 ..
11533 }) = undelivered.first() {
11534 tracing::warn!(
11540 call_id = call_id.as_str(),
11541 content_type = ?request.content_type,
11542 cause = "no-subscribers",
11543 "harness MCP proxy event could not be published",
11544 );
11545 }
11546 false
11547 }
11548 }
11549 }
11550
11551 fn publish_terminal_frames(&self) {
11552 let snapshot = self.handle.snapshot();
11553 let bindings = self
11554 .session_bindings
11555 .lock()
11556 .unwrap_or_else(|poisoned| poisoned.into_inner());
11557 let candidates = snapshot
11558 .sessions
11559 .iter()
11560 .filter_map(|session| {
11561 let binding = bindings.get(&session.instance_id)?;
11562 if binding.generation != session.generation {
11563 return None;
11564 }
11565 let frame = session.terminal_frame.clone()?;
11566 crate::session_signals::feed(
11567 binding.workspace_id.as_str(),
11568 session.instance_id.0,
11569 session.agent_id.as_str(),
11570 &frame.contents,
11571 );
11572 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11573 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11574 Some((
11575 SessionAddress {
11576 workspace_id: binding.workspace_id.clone(),
11577 session: SessionKey {
11578 instance_id: session.instance_id,
11579 generation: session.generation,
11580 },
11581 },
11582 frame,
11583 ))
11584 })
11585 .collect();
11586 drop(bindings);
11587 self.publish_terminal_frame_candidates(candidates);
11588 }
11589
11590 fn publish_terminal_frame_candidates(
11591 &self,
11592 candidates: Vec<(SessionAddress, TerminalFrame)>,
11593 ) {
11594 let mut watermarks = self
11595 .terminal_frame_watermarks
11596 .lock()
11597 .unwrap_or_else(|poisoned| poisoned.into_inner());
11598 let mut events = Vec::with_capacity(candidates.len());
11599 for (address, frame) in candidates {
11600 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11601 let advanced = watermarks
11602 .get(&address.session.instance_id)
11603 .map_or(true, |(current_address, sequence)| {
11604 current_address != &address || frame.sequence > *sequence
11605 });
11606 if !advanced {
11607 continue;
11608 }
11609 if is_first_frame {
11610 if self.controller_state().is_none() {
11624 tracing::warn!(
11625 node_id = %self.node_id,
11626 workspace_id = %address.workspace_id,
11627 session = ?address.session,
11628 terminal_size = ?frame.size,
11629 "session producing output with no controller attached",
11630 );
11631 } else {
11632 tracing::info!(
11633 node_id = %self.node_id,
11634 workspace_id = %address.workspace_id,
11635 session = ?address.session,
11636 terminal_size = ?frame.size,
11637 "session produced its first output",
11638 );
11639 }
11640 }
11641 watermarks.insert(
11642 address.session.instance_id,
11643 (address.clone(), frame.sequence),
11644 );
11645 if let Some((at, reported)) = self
11646 .pty_input_marks
11647 .lock()
11648 .unwrap_or_else(|poisoned| poisoned.into_inner())
11649 .get_mut(&address.session.instance_id)
11650 {
11651 if !*reported && at.elapsed() < Duration::from_secs(5) {
11653 *reported = true;
11654 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");
11655 }
11656 }
11657 events.push(NodeEvent::TerminalFrame { address, frame });
11658 }
11659 drop(watermarks);
11660 if events.is_empty() {
11661 return;
11662 }
11663 let mut history = self
11664 .history
11665 .lock()
11666 .unwrap_or_else(|poisoned| poisoned.into_inner());
11667 let mut envelopes = Vec::with_capacity(events.len());
11668 for event in events {
11669 let sequence = history
11670 .last_sequence
11671 .checked_add(1)
11672 .expect("node event sequence exhausted");
11673 history.last_sequence = sequence;
11674 envelopes.push(NodeEventEnvelope { sequence, event });
11675 }
11676 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11677 drop(history);
11678 }
11679
11680 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11689 let mut history = self
11690 .history
11691 .lock()
11692 .unwrap_or_else(|poisoned| poisoned.into_inner());
11693 let sequence = history
11694 .last_sequence
11695 .checked_add(1)
11696 .expect("node event sequence exhausted");
11697 history.last_sequence = sequence;
11698 drop(history);
11699 tracing::debug!(
11707 source_sequence = chunk.source_sequence,
11708 kind = agent_stream_chunk_kind_label(&chunk.kind),
11709 envelope_sequence = sequence,
11710 "publishing agent stream chunk",
11711 );
11712 let envelope = NodeEventEnvelope {
11713 sequence,
11714 event: NodeEvent::AgentStream { address, chunk },
11715 };
11716 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11717 }
11718
11719 fn current_sequence(&self) -> u64 {
11720 self.history
11721 .lock()
11722 .unwrap_or_else(|poisoned| poisoned.into_inner())
11723 .last_sequence
11724 }
11725
11726 fn controller_state(&self) -> Option<ControllerState> {
11727 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11728 let lease = controller.as_ref().copied()?;
11729 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11730 if remaining.is_zero() {
11731 *controller = None;
11732 return None;
11733 }
11734 Some(ControllerState {
11735 connection_id: lease.connection_id,
11736 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11737 })
11738 }
11739
11740 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11741 if role != ClientRole::Operator {
11742 tracing::warn!(
11743 node_id = %self.node_id,
11744 connection_id,
11745 "controller lease rejected: observer connections cannot acquire control",
11746 );
11747 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11748 }
11749 let lease_ms = if lease_ms == 0 {
11750 DEFAULT_CONTROLLER_LEASE_MS
11751 } else {
11752 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11753 };
11754 let now = Instant::now();
11755 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11756 if let Some(active) = controller.as_ref() {
11757 if active.connection_id != connection_id && active.expires_at > now {
11758 tracing::warn!(
11759 node_id = %self.node_id,
11760 connection_id,
11761 holder_connection_id = active.connection_id,
11762 "controller lease rejected: another operator holds it",
11763 );
11764 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11765 }
11766 }
11767 let is_new_attach = match controller.as_ref() {
11774 Some(active) => active.connection_id != connection_id,
11775 None => true,
11776 };
11777 *controller = Some(ControllerLease {
11778 connection_id,
11779 expires_at: now + Duration::from_millis(lease_ms),
11780 });
11781 drop(controller);
11782 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11783 if is_new_attach {
11784 tracing::info!(
11785 node_id = %self.node_id,
11786 connection_id,
11787 lease_ms,
11788 "controller lease acquired",
11789 );
11790 } else {
11791 tracing::debug!(
11792 node_id = %self.node_id,
11793 connection_id,
11794 lease_ms,
11795 "controller lease renewed",
11796 );
11797 }
11798 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11799 Ok(state)
11800 }
11801
11802 fn release_controller(&self, connection_id: u64) -> bool {
11803 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11804 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11805 if released {
11806 *controller = None;
11807 }
11808 drop(controller);
11809 if released {
11810 tracing::info!(
11811 node_id = %self.node_id,
11812 connection_id,
11813 "controller lease released",
11814 );
11815 self.publish(NodeEvent::ControllerChanged { controller: None });
11816 }
11817 released
11818 }
11819
11820 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11829 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11830 return;
11831 };
11832 let id = address.session.instance_id;
11833 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11834 return;
11835 };
11836 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11837 .iter()
11838 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11839 .collect();
11840 let (writes, set_model, effort_gap) =
11841 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11842 if set_model {
11843 if let Some(model_id) = model.clone() {
11844 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11845 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11846 }
11847 }
11848 }
11849 for (option_id, value_json) in writes {
11850 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11851 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11852 }
11853 }
11854 if effort_gap {
11855 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11856 }
11857 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11858 }
11859
11860 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11863 let id = address.session.instance_id;
11864 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11865 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11866 crate::auto_permit::forget_acp(id.0);
11867 return;
11868 }
11869 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11870 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11871 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11872 }
11873 match provider_event {
11874 ProviderEvent::ModeChanged { mode_id, available } => {
11875 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11876 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11877 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11878 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11879 }
11880 }
11881 }
11882 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11883 match self.dispatch(ControlCommand::ResolveInteraction {
11884 instance_id: id,
11885 generation: address.session.generation,
11886 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11887 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11888 }) {
11889 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11890 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11891 }
11892 }
11893 _ => {}
11896 }
11897 }
11898
11899 fn mark_spawn_provisional(&self, address: SessionAddress) {
11900 let id = address.session.instance_id;
11901 self.provisional_spawns
11902 .lock()
11903 .unwrap_or_else(|poisoned| poisoned.into_inner())
11904 .insert(id, (address, Instant::now()));
11905 tracing::info!(
11906 node_id = %self.node_id,
11907 instance_id = id.0,
11908 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11909 "draft spawn; waiting for HQ view",
11910 );
11911 }
11912
11913 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11914 let removed = self
11915 .provisional_spawns
11916 .lock()
11917 .unwrap_or_else(|poisoned| poisoned.into_inner())
11918 .remove(&instance_id);
11919 if removed.is_some() {
11920 tracing::info!(
11921 node_id = %self.node_id,
11922 instance_id = instance_id.0,
11923 "draft spawn claimed by HQ view",
11924 );
11925 }
11926 }
11927
11928 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11929 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11930 let mut map = self
11931 .provisional_spawns
11932 .lock()
11933 .unwrap_or_else(|poisoned| poisoned.into_inner());
11934 let due: Vec<AgentInstanceId> = map
11935 .iter()
11936 .filter(|(_, (_, at))| at.elapsed() >= grace)
11937 .map(|(id, _)| *id)
11938 .collect();
11939 due.into_iter()
11940 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11941 .collect()
11942 }
11943
11944 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11945 let instance_id = address.session.instance_id;
11946 tracing::warn!(
11947 node_id = %self.node_id,
11948 instance_id = instance_id.0,
11949 workspace = %address.workspace_id,
11950 reason = "attach_never_arrived",
11951 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11952 "draft spawn never viewed; stopping PTY",
11953 );
11954 let _ = self.dispatch(ControlCommand::Stop {
11955 instance_id,
11956 force: true,
11957 });
11958 let _ = self.remove_session(&address).await;
11959 }
11960
11961 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11962 if role != ClientRole::Operator {
11963 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11964 }
11965 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11966 let now = Instant::now();
11967 match controller.as_ref().copied() {
11968 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11969 Some(active) if active.expires_at <= now => {
11970 *controller = None;
11971 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11972 }
11973 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11974 }
11975 }
11976
11977 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11978 if !self
11979 .workspaces
11980 .read()
11981 .unwrap_or_else(|poisoned| poisoned.into_inner())
11982 .contains_key(&address.workspace_id)
11983 {
11984 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11985 }
11986 let binding = self
11987 .session_bindings
11988 .lock()
11989 .unwrap_or_else(|poisoned| poisoned.into_inner())
11990 .get(&address.session.instance_id)
11991 .cloned()
11992 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11993 if binding.workspace_id != address.workspace_id {
11994 return Err(failure(
11995 NodeFailureCode::SessionWorkspaceMismatch,
11996 "session belongs to another workspace",
11997 ));
11998 }
11999 if binding.generation != address.session.generation {
12000 return Err(failure(
12001 NodeFailureCode::StaleGeneration,
12002 &format!(
12003 "stale session generation: expected {}, received {}",
12004 binding.generation.0,
12005 address.session.generation.0,
12006 ),
12007 ));
12008 }
12009 let snapshot = self.handle.snapshot();
12010 let Some(current) = snapshot
12011 .sessions
12012 .iter()
12013 .find(|item| item.instance_id == address.session.instance_id)
12014 else {
12015 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12016 };
12017 if current.generation != address.session.generation {
12018 return Err(failure(
12019 NodeFailureCode::StaleGeneration,
12020 &format!(
12021 "stale session generation: expected {}, received {}",
12022 current.generation.0,
12023 address.session.generation.0,
12024 ),
12025 ));
12026 }
12027 Ok(current.agent_id.clone())
12028 }
12029
12030 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12031 self.dispatch_envelope(self.prepare_command(command))
12032 }
12033
12034 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12035 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12036 CommandEnvelope {
12037 id: CommandId(id),
12038 command,
12039 }
12040 }
12041
12042 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12043 let command_id = envelope.id;
12044 self.handle
12045 .dispatch(envelope)
12046 .map_err(|error| match error {
12047 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12048 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12049 })?;
12050 Ok(command_id)
12051 }
12052
12053 fn record_rejected_command(
12062 &self,
12063 command_id: CommandId,
12064 message: String,
12065 unsupported_transport: bool,
12066 ) {
12067 let now = Instant::now();
12068 let mut reasons = self.rejected_command_reasons.lock()
12069 .unwrap_or_else(|poisoned| poisoned.into_inner());
12070 reasons.retain(|_, reason| reason.expires_at > now);
12071 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12072 if let Some(&oldest) = reasons.keys().next() {
12073 reasons.remove(&oldest);
12074 }
12075 }
12076 reasons.insert(
12077 command_id,
12078 RejectedCommandReason {
12079 message,
12080 unsupported_transport,
12081 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12082 },
12083 );
12084 }
12085
12086 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12093 let now = Instant::now();
12094 let mut reasons = self.rejected_command_reasons.lock()
12095 .unwrap_or_else(|poisoned| poisoned.into_inner());
12096 match reasons.remove(&command_id) {
12097 Some(reason) if reason.expires_at > now => Some(reason),
12098 _ => None,
12099 }
12100 }
12101
12102 async fn dispatch_bounded(
12103 &self,
12104 command: ControlCommand,
12105 timeout_duration: Duration,
12106 ) -> Result<CommandId, NodeFailure> {
12107 let deadline = Instant::now() + timeout_duration;
12108 loop {
12109 match self.dispatch(command.clone()) {
12110 Ok(command_id) => return Ok(command_id),
12111 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12112 sleep(Duration::from_millis(2)).await;
12113 }
12114 Err(error) => return Err(error),
12115 }
12116 }
12117 }
12118
12119 fn internal_session_snapshot(
12120 &self,
12121 address: &SessionAddress,
12122 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12123 self.validate_address(address)?;
12124 self.handle
12125 .snapshot()
12126 .sessions
12127 .clone()
12128 .into_iter()
12129 .find(|session| {
12130 session.instance_id == address.session.instance_id
12131 && session.generation == address.session.generation
12132 })
12133 .ok_or_else(|| {
12134 failure(
12135 NodeFailureCode::UnknownSession,
12136 "session snapshot is unavailable",
12137 )
12138 })
12139 }
12140
12141 fn require_acp_session(
12149 &self,
12150 address: &SessionAddress,
12151 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12152 let snapshot = self.internal_session_snapshot(address)?;
12153 if snapshot.transport != TransportKind::Acp {
12154 return Err(failure(
12155 NodeFailureCode::UnsupportedTransport,
12156 "this verb requires a session spawned over the ACP transport",
12157 ));
12158 }
12159 Ok(snapshot)
12160 }
12161
12162 async fn dispatch_history_bounded(
12163 &self,
12164 address: &SessionAddress,
12165 command: ControlCommand,
12166 operation: HistoryOperation,
12167 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12168 self.validate_address(address)?;
12169 let started_after = self.current_sequence();
12170 let command_id = self
12171 .dispatch_bounded(
12172 command,
12173 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12174 )
12175 .await?;
12176 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12177 let mut scan_after = started_after;
12178 let mut accepted_after = None;
12179 loop {
12180 let events = self
12181 .history
12182 .lock()
12183 .unwrap_or_else(|poisoned| poisoned.into_inner())
12184 .events
12185 .iter()
12186 .filter(|envelope| envelope.sequence > scan_after)
12187 .cloned()
12188 .collect::<Vec<_>>();
12189 for envelope in events {
12190 scan_after = scan_after.max(envelope.sequence);
12191 let NodeEvent::Control {
12192 address: event_address,
12193 event,
12194 } = envelope.event
12195 else {
12196 continue;
12197 };
12198 if event_address != *address {
12199 continue;
12200 }
12201 if event.command_id == Some(command_id) {
12202 match &event.event {
12203 ControlEventKind::CommandRejected { .. } => {
12204 return Err(failure(
12205 NodeFailureCode::InvalidRequest,
12206 "history request was rejected",
12207 ));
12208 }
12209 ControlEventKind::HistoryRequested {
12210 operation: requested,
12211 ..
12212 } if requested == &operation => {
12213 accepted_after = Some(envelope.sequence);
12214 continue;
12215 }
12216 _ => {}
12217 }
12218 }
12219 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12220 match (&operation, &event.event) {
12221 (
12222 HistoryOperation::Discover { .. },
12223 ControlEventKind::HistoryDiscovered { .. },
12224 )
12225 | (
12226 HistoryOperation::Load { .. },
12227 ControlEventKind::HistoryLoaded { .. },
12228 ) => return self.internal_session_snapshot(address),
12229 (_, ControlEventKind::HistoryFailed { .. }) => {
12230 return Err(failure(
12231 NodeFailureCode::BackendOperationFailed,
12232 "history operation failed",
12233 ));
12234 }
12235 _ => {}
12236 }
12237 }
12238 }
12239 if Instant::now() >= deadline {
12240 return Err(failure(
12241 NodeFailureCode::BackendBusy,
12242 if accepted_after.is_some() {
12243 "history operation did not settle before the bounded deadline"
12244 } else {
12245 "history operation was not accepted before the bounded deadline"
12246 },
12247 ));
12248 }
12249 sleep(Duration::from_millis(2)).await;
12250 }
12251 }
12252
12253 async fn discover_history(
12254 &self,
12255 address: &SessionAddress,
12256 limit: u16,
12257 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12258 let query = HistoryQuery {
12259 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12260 limit,
12261 };
12262 query
12263 .validate()
12264 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12265 let snapshot = self
12266 .dispatch_history_bounded(
12267 address,
12268 ControlCommand::DiscoverHistory {
12269 instance_id: address.session.instance_id,
12270 query: query.clone(),
12271 },
12272 HistoryOperation::Discover { query },
12273 )
12274 .await?;
12275 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12276 return Err(failure(
12277 NodeFailureCode::BackendOperationFailed,
12278 "history discovery settled into an invalid state",
12279 ));
12280 }
12281 Ok(snapshot.history.candidates)
12282 }
12283
12284 async fn load_history(
12285 &self,
12286 address: &SessionAddress,
12287 candidate_id: String,
12288 ) -> Result<HistorySessionRecord, NodeFailure> {
12289 validate_candidate_id(&candidate_id)
12290 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12291 let snapshot = self
12292 .dispatch_history_bounded(
12293 address,
12294 ControlCommand::LoadHistory {
12295 instance_id: address.session.instance_id,
12296 candidate_id: candidate_id.clone(),
12297 },
12298 HistoryOperation::Load {
12299 candidate_id: candidate_id.clone(),
12300 },
12301 )
12302 .await?;
12303 if snapshot.history.pending.is_some()
12304 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12305 {
12306 return Err(failure(
12307 NodeFailureCode::BackendOperationFailed,
12308 "history load settled into an invalid state",
12309 ));
12310 }
12311 snapshot.history.loaded.ok_or_else(|| {
12312 failure(
12313 NodeFailureCode::BackendOperationFailed,
12314 "history load produced no bounded session",
12315 )
12316 })
12317 }
12318
12319 async fn export_context_pack_for_session_record(
12326 &self,
12327 record_id: &SessionRecordId,
12328 session: &SessionAddress,
12329 allow_clean_detachment: bool,
12330 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12331 let outcome = self
12337 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12338 .await;
12339 if let Err(failure) = &outcome {
12340 tracing::warn!(
12341 record = %record_id,
12342 code = ?failure.code,
12343 reason = %failure.message,
12344 "session-record context-pack export refused",
12345 );
12346 }
12347 outcome
12348 }
12349
12350 async fn export_context_pack_for_session_record_inner(
12351 &self,
12352 record_id: &SessionRecordId,
12353 session: &SessionAddress,
12354 allow_clean_detachment: bool,
12355 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12356 let step = |name: &'static str| move |failure: NodeFailure| {
12361 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12362 failure
12363 };
12364 let record = self.record(record_id).map_err(step("record-lookup"))?;
12365 let identity = match record.provider_session.clone() {
12381 Some(identity) => {
12382 if identity.key != ProviderSessionKey::SessionId {
12383 return Err(step("provider-identity-key")(failure(
12384 NodeFailureCode::BackendOperationFailed,
12385 "managed session provider identity cannot bind history exactly",
12386 )));
12387 }
12388 Some(identity)
12389 }
12390 None if record.state == ManagedSessionState::IdentityPending => {
12391 return Err(step("provider-session-identity")(failure(
12392 NodeFailureCode::BackendOperationFailed,
12393 "managed session has not yet observed its provider session identity",
12394 )));
12395 }
12396 None => None,
12397 };
12398 self.revalidate_session_record_context_export(
12399 &record,
12400 identity.as_ref(),
12401 session,
12402 allow_clean_detachment,
12403 ).map_err(step("revalidate-before-discovery"))?;
12404 let candidates = self
12405 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12406 .await
12407 .map_err(step("discover-history"))?;
12408 let candidate_id = select_history_candidate(
12413 &candidates,
12414 identity.as_ref().map(|identity| identity.id.as_str()),
12415 ).map_err(step("unique-candidate"))?;
12416 let loaded = self.load_history(session, candidate_id.clone()).await
12417 .map_err(step("load-history"))?;
12418 if let Some(identity) = identity.as_ref() {
12419 if loaded.session_id != identity.id {
12420 return Err(failure(
12421 NodeFailureCode::SessionRecordConflict,
12422 "loaded history identity does not match the managed session record",
12423 ));
12424 }
12425 }
12426 self.revalidate_session_record_context_export(
12427 &record,
12428 identity.as_ref(),
12429 session,
12430 allow_clean_detachment,
12431 )?;
12432 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12433 .map_err(step("revalidate-loaded-history"))?;
12434 let pack = self.materialize_context_pack(session).await
12435 .map_err(step("materialize-context-pack"))?;
12436 self.commit_context_pack_for_session_record(
12437 &record,
12438 identity.as_ref(),
12439 session,
12440 &candidate_id,
12441 &loaded,
12442 pack,
12443 allow_clean_detachment,
12444 )
12445 }
12446
12447 async fn export_context_pack(
12448 &self,
12449 address: &SessionAddress,
12450 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12451 let pack = self.materialize_context_pack(address).await?;
12452 self.commit_context_pack(pack)
12453 }
12454
12455 async fn materialize_context_pack(
12456 &self,
12457 address: &SessionAddress,
12458 ) -> Result<NodeContextPack, NodeFailure> {
12459 let snapshot = self.internal_session_snapshot(address)?;
12460 if !context_pack_source_status_is_usable(&snapshot.status) {
12461 return Err(failure(
12462 NodeFailureCode::InvalidRequest,
12463 "context export requires a running or successfully exited source session",
12464 ));
12465 }
12466 if snapshot.history.pending.is_some()
12467 || snapshot.history.loaded_candidate_id.is_none()
12468 {
12469 return Err(failure(
12470 NodeFailureCode::InvalidRequest,
12471 "context export requires settled loaded history",
12472 ));
12473 }
12474 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12475 failure(
12476 NodeFailureCode::InvalidRequest,
12477 "context export requires settled loaded history",
12478 )
12479 })?;
12480 let source_provider = snapshot.agent_id.clone();
12481 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12482 let repository = self
12483 .context_pack_repository(&address.workspace_id)
12484 .await?;
12485 let current = self.internal_session_snapshot(address)?;
12486 if !context_pack_source_status_is_usable(¤t.status)
12487 || current.agent_id != source_provider
12488 || current.history.pending.is_some()
12489 || current.history.loaded_candidate_id != loaded_candidate_id
12490 || current.history.loaded.as_ref() != Some(&loaded)
12491 {
12492 return Err(failure(
12493 NodeFailureCode::ContextPackMaterializationFailed,
12494 "context source changed during bounded repository capture",
12495 ));
12496 }
12497 NodeContextPack::export_with_repository(
12498 ContextPackLineageReceipt {
12499 source_node_id: self.node_id.clone(),
12500 source_session: address.clone(),
12501 source_provider,
12502 },
12503 &loaded,
12504 Some(repository),
12505 )
12506 .map_err(|_| {
12507 failure(
12508 NodeFailureCode::ContextPackMaterializationFailed,
12509 "bounded context pack export failed",
12510 )
12511 })
12512 }
12513
12514 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12520 let mut store = self
12521 .context_pack_store
12522 .lock()
12523 .unwrap_or_else(|poisoned| poisoned.into_inner());
12524 let Some(store) = store.as_mut() else {
12525 return Ok(());
12526 };
12527 store.commit(pack).map_err(|_| {
12528 failure(
12529 NodeFailureCode::ContextPackMaterializationFailed,
12530 "durable context pack commit failed",
12531 )
12532 })
12533 }
12534
12535 fn commit_context_pack(
12536 &self,
12537 pack: NodeContextPack,
12538 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12539 self.commit_context_pack_durably(&pack)?;
12540 self.context_catalog
12541 .write()
12542 .unwrap_or_else(|poisoned| poisoned.into_inner())
12543 .insert(pack)
12544 .map_err(|_| {
12545 failure(
12546 NodeFailureCode::ContextPackMaterializationFailed,
12547 "context pack catalog is full",
12548 )
12549 })
12550 }
12551
12552 fn commit_context_pack_for_session_record(
12553 &self,
12554 expected: &ManagedSessionRecord,
12555 identity: Option<&ProviderSessionIdentity>,
12556 session: &SessionAddress,
12557 candidate_id: &str,
12558 loaded: &HistorySessionRecord,
12559 pack: NodeContextPack,
12560 allow_clean_detachment: bool,
12561 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12562 let current_root = self.workspace_root(&expected.workspace_id)?;
12563 let _transaction = self
12564 .state_transaction
12565 .lock()
12566 .unwrap_or_else(|poisoned| poisoned.into_inner());
12567 let records = self
12568 .session_records
12569 .lock()
12570 .unwrap_or_else(|poisoned| poisoned.into_inner());
12571 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12572 failure(
12573 NodeFailureCode::SessionRecordConflict,
12574 "managed session record disappeared during context export",
12575 )
12576 })?;
12577 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12578 return Err(failure(
12579 NodeFailureCode::SessionRecordConflict,
12580 "managed session record changed during context export",
12581 ));
12582 }
12583 let bindings = self
12584 .session_bindings
12585 .lock()
12586 .unwrap_or_else(|poisoned| poisoned.into_inner());
12587 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12588 failure(
12589 NodeFailureCode::SessionRecordConflict,
12590 "managed session runtime binding disappeared during context export",
12591 )
12592 })?;
12593 let runtime = self
12594 .handle
12595 .snapshot()
12596 .sessions
12597 .iter()
12598 .find(|snapshot| {
12599 snapshot.instance_id == session.session.instance_id
12600 && snapshot.generation == session.session.generation
12601 })
12602 .cloned()
12603 .ok_or_else(|| {
12604 failure(
12605 NodeFailureCode::SessionRecordConflict,
12606 "managed session runtime disappeared during context export",
12607 )
12608 })?;
12609 let receipt = pack.receipt();
12610 if !session_record_context_export_binding_is_exact(
12611 expected,
12612 current,
12613 identity,
12614 session,
12615 binding,
12616 &runtime.agent_id,
12617 ¤t_root,
12618 allow_clean_detachment,
12619 ) || !session_record_context_export_source_is_usable(
12620 current,
12621 &runtime.status,
12622 allow_clean_detachment,
12623 ) || runtime.history.pending.is_some()
12624 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12625 || runtime.history.loaded.as_ref() != Some(loaded)
12626 || receipt.lineage.source_node_id != self.node_id
12627 || receipt.lineage.source_session != *session
12628 || receipt.lineage.source_provider != current.provider
12629 || receipt.source_message_count != loaded.message_count
12630 {
12631 return Err(failure(
12632 NodeFailureCode::SessionRecordConflict,
12633 "managed session source changed before context catalog commit",
12634 ));
12635 }
12636 self.commit_context_pack_durably(&pack)?;
12637 self.context_catalog
12638 .write()
12639 .unwrap_or_else(|poisoned| poisoned.into_inner())
12640 .insert(pack)
12641 .map_err(|_| {
12642 failure(
12643 NodeFailureCode::ContextPackMaterializationFailed,
12644 "context pack catalog is full",
12645 )
12646 })
12647 }
12648
12649 async fn context_pack_repository(
12650 &self,
12651 workspace_id: &WorkspaceId,
12652 ) -> Result<ContextPackRepository, NodeFailure> {
12653 let first = self
12654 .context_pack_repository_observation(workspace_id)
12655 .await?;
12656 let second = self
12657 .context_pack_repository_observation(workspace_id)
12658 .await?;
12659 stable_context_pack_repository(first, second)
12660 }
12661
12662 async fn context_pack_repository_observation(
12663 &self,
12664 workspace_id: &WorkspaceId,
12665 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12666 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12667 let head = if git.is_repository {
12668 let root = self.workspace_root(workspace_id)?;
12669 match resolve_base_commit_with_timeout(
12670 &root,
12671 "HEAD",
12672 GIT_COMMAND_TIMEOUT_MS,
12673 )
12674 .await
12675 {
12676 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12677 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12678 ContextPackRepositoryHead::Unborn
12679 }
12680 Err(_) => {
12681 return Err(failure(
12682 NodeFailureCode::ContextPackMaterializationFailed,
12683 "context pack HEAD observation failed",
12684 ));
12685 }
12686 }
12687 } else {
12688 ContextPackRepositoryHead::NotRepository
12689 };
12690 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12691 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12692 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12693 failure(
12694 NodeFailureCode::InvalidRepositoryPath,
12695 "context pack selected file path is invalid",
12696 )
12697 })?;
12698 match self.read_workspace_file(workspace_id.clone(), path).await {
12699 Ok(WorkspaceFileRead {
12700 content: WorkspaceFileContent::Utf8 { text, byte_len },
12701 ..
12702 }) => files.push(ContextPackRepositoryFileSource::utf8(
12703 selected_path,
12704 text,
12705 byte_len,
12706 )),
12707 Ok(WorkspaceFileRead {
12708 content: WorkspaceFileContent::NonUtf8 { byte_len },
12709 ..
12710 }) => files.push(ContextPackRepositoryFileSource::skipped(
12711 selected_path,
12712 ContextPackSelectedFileSkipReason::NonUtf8,
12713 Some(byte_len),
12714 )),
12715 Ok(WorkspaceFileRead {
12716 content: WorkspaceFileContent::TooLarge { .. },
12717 ..
12718 }) => files.push(ContextPackRepositoryFileSource::skipped(
12719 selected_path,
12720 ContextPackSelectedFileSkipReason::TooLarge,
12721 None,
12722 )),
12723 Err(error) => {
12724 let reason = if matches!(
12725 error.code,
12726 NodeFailureCode::InvalidRepositoryPath
12727 | NodeFailureCode::RepositoryFileNotRegular
12728 | NodeFailureCode::RepositoryPathUnsafe
12729 ) {
12730 ContextPackSelectedFileSkipReason::Unsafe
12731 } else {
12732 ContextPackSelectedFileSkipReason::Unavailable
12733 };
12734 files.push(ContextPackRepositoryFileSource::skipped(
12735 selected_path,
12736 reason,
12737 None,
12738 ));
12739 }
12740 }
12741 }
12742 Ok(ContextPackRepositoryObservation { head, git, files })
12743 }
12744
12745 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12746 let referenced_by_binding = self
12747 .session_bindings
12748 .lock()
12749 .unwrap_or_else(|poisoned| poisoned.into_inner())
12750 .values()
12751 .any(|binding| {
12752 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12753 });
12754 let referenced_by_record = self
12755 .session_records
12756 .lock()
12757 .unwrap_or_else(|poisoned| poisoned.into_inner())
12758 .records
12759 .values()
12760 .any(|record| record.context_id.as_ref() == Some(context_id));
12761 let referenced_by_materialization = self
12762 .materializations
12763 .lock()
12764 .unwrap_or_else(|poisoned| poisoned.into_inner())
12765 .values()
12766 .any(|ownership| {
12767 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12768 });
12769 if referenced_by_binding
12770 || referenced_by_record
12771 || referenced_by_materialization
12772 {
12773 return Err(failure(
12774 NodeFailureCode::ContextPackBusy,
12775 "context pack is still referenced",
12776 ));
12777 }
12778 let mut catalog = self.context_catalog
12779 .write()
12780 .unwrap_or_else(|poisoned| poisoned.into_inner());
12781 if catalog.get(context_id).is_none() {
12782 return Err(failure(
12783 NodeFailureCode::UnknownContextPack,
12784 "context pack is unavailable",
12785 ));
12786 }
12787 self.spawn_idempotency
12788 .lock()
12789 .unwrap_or_else(|poisoned| poisoned.into_inner())
12790 .entries
12791 .retain(|_, entry| !entry.value.references_context(context_id));
12792 let removed = catalog.remove(context_id);
12793 debug_assert!(removed.is_some(), "validated context pack remains present");
12794 Ok(())
12795 }
12796
12797 async fn dispatch_input_bounded(
12798 &self,
12799 address: &SessionAddress,
12800 action: InputAction,
12801 ) -> Result<(), NodeFailure> {
12802 let started_after = self.current_sequence();
12803 self.pty_input_marks
12804 .lock()
12805 .unwrap_or_else(|poisoned| poisoned.into_inner())
12806 .insert(address.session.instance_id, (Instant::now(), false));
12807 self.pty_input_wake.notify_one();
12808 let command_id = self
12809 .dispatch_bounded(
12810 ControlCommand::SendInput {
12811 instance_id: address.session.instance_id,
12812 action,
12813 },
12814 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12815 )
12816 .await?;
12817 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12821 let mut scan_after = started_after;
12822 let mut accepted_after = None;
12823 loop {
12824 let events = self
12825 .history
12826 .lock()
12827 .unwrap_or_else(|poisoned| poisoned.into_inner())
12828 .events
12829 .iter()
12830 .filter(|envelope| envelope.sequence > scan_after)
12831 .cloned()
12832 .collect::<Vec<_>>();
12833 for envelope in events {
12834 scan_after = scan_after.max(envelope.sequence);
12835 let NodeEvent::Control {
12836 address: event_address,
12837 event,
12838 } = envelope.event
12839 else {
12840 continue;
12841 };
12842 if event_address != *address {
12843 continue;
12844 }
12845 if event.command_id == Some(command_id) {
12846 match &event.event {
12847 ControlEventKind::CommandRejected { message } => {
12848 return Err(failure(NodeFailureCode::InvalidRequest, message));
12849 }
12850 ControlEventKind::InputRequested { .. } => {
12851 accepted_after = Some(envelope.sequence);
12852 continue;
12853 }
12854 _ => {}
12855 }
12856 }
12857 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12858 match &event.event {
12859 ControlEventKind::InputCompleted { .. } => return Ok(()),
12860 ControlEventKind::InputFailed { message, .. } => {
12861 return Err(failure(
12862 NodeFailureCode::BackendOperationFailed,
12863 message,
12864 ));
12865 }
12866 _ => {}
12867 }
12868 }
12869 }
12870 if Instant::now() >= deadline {
12871 return Err(failure(
12872 NodeFailureCode::BackendBusy,
12873 if accepted_after.is_some() {
12874 "input was accepted but did not settle before the bounded deadline"
12875 } else {
12876 "input was not accepted before the bounded deadline"
12877 },
12878 ));
12879 }
12880 sleep(Duration::from_millis(2)).await;
12881 }
12882 }
12883
12884 async fn dispatch_resize_bounded(
12885 &self,
12886 address: &SessionAddress,
12887 size: gate4agent_types::TerminalSize,
12888 ) -> Result<(), NodeFailure> {
12889 let started_after = self.current_sequence();
12890 let command_id = self
12891 .dispatch_bounded(
12892 ControlCommand::Resize {
12893 instance_id: address.session.instance_id,
12894 size,
12895 },
12896 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12897 )
12898 .await?;
12899 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12900 let mut scan_after = started_after;
12901 let mut accepted_after = None;
12902 loop {
12903 let events = self
12904 .history
12905 .lock()
12906 .unwrap_or_else(|poisoned| poisoned.into_inner())
12907 .events
12908 .iter()
12909 .filter(|envelope| envelope.sequence > scan_after)
12910 .cloned()
12911 .collect::<Vec<_>>();
12912 for envelope in events {
12913 scan_after = scan_after.max(envelope.sequence);
12914 let NodeEvent::Control {
12915 address: event_address,
12916 event,
12917 } = envelope.event
12918 else {
12919 continue;
12920 };
12921 if event_address != *address {
12922 continue;
12923 }
12924 if event.command_id == Some(command_id) {
12925 match &event.event {
12926 ControlEventKind::CommandRejected { message } => {
12927 return Err(failure(NodeFailureCode::InvalidRequest, message));
12928 }
12929 ControlEventKind::ResizeRequested {
12930 size: requested_size,
12931 ..
12932 } if *requested_size == size => {
12933 accepted_after = Some(envelope.sequence);
12934 continue;
12935 }
12936 _ => {}
12937 }
12938 }
12939 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12940 match &event.event {
12941 ControlEventKind::Resized { size: settled_size }
12942 if *settled_size == size =>
12943 {
12944 return Ok(());
12945 }
12946 ControlEventKind::ResizeFailed { message } => {
12947 return Err(failure(
12948 NodeFailureCode::BackendOperationFailed,
12949 message,
12950 ));
12951 }
12952 _ => {}
12953 }
12954 }
12955 }
12956 if Instant::now() >= deadline {
12957 return Err(failure(
12958 NodeFailureCode::BackendBusy,
12959 if accepted_after.is_some() {
12960 "resize was accepted but did not settle before the bounded deadline"
12961 } else {
12962 "resize was not accepted before the bounded deadline"
12963 },
12964 ));
12965 }
12966 sleep(Duration::from_millis(2)).await;
12967 }
12968 }
12969
12970 async fn spawn_session(
12971 &self,
12972 workspace_id: WorkspaceId,
12973 provider: AgentId,
12974 mode: SessionMode,
12975 terminal_size: gate4agent_types::TerminalSize,
12976 initial_prompt: Option<String>,
12977 ) -> Result<SessionAddress, NodeFailure> {
12978 if self.managed_worktrees.lock()
12979 .unwrap_or_else(|poisoned| poisoned.into_inner())
12980 .lease_for_workspace(&workspace_id)
12981 .is_some()
12982 {
12983 return Err(failure(
12984 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12985 "managed worktree targets may only be spawned through their lease request",
12986 ));
12987 }
12988 self
12989 .spawn_session_with_deadline(
12990 workspace_id,
12991 provider.clone(),
12992 mode,
12993 terminal_size,
12994 initial_prompt,
12995 None,
12996 None,
12997 None,
12998 None,
12999 None,
13000 SpawnRecordPolicy::ProviderIdentityOnly,
13001 None,
13002 &[],
13003 None,
13004 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13011 None,
13012 None,
13013 )
13014 .await
13015 .map(|(session, _runtime_policy)| session)
13016 }
13017
13018 async fn spawn_session_with_deadline(
13019 &self,
13020 workspace_id: WorkspaceId,
13021 provider: AgentId,
13022 mode: SessionMode,
13023 terminal_size: gate4agent_types::TerminalSize,
13024 initial_prompt: Option<String>,
13025 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13026 bundle: Option<ResolvedBundleReceipt>,
13027 context: Option<ResolvedContextPackReceipt>,
13028 managed_authority: Option<ManagedWorktreeLeaseId>,
13029 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13030 record_policy: SpawnRecordPolicy,
13031 deadline: Option<Instant>,
13032 required_capabilities: &[ProviderRuntimeCapability],
13033 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13034 approval_level: ApprovalLevel,
13035 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13036 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13042 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13043 let reserved_lease = self.managed_worktrees.lock()
13044 .unwrap_or_else(|poisoned| poisoned.into_inner())
13045 .lease_for_workspace(&workspace_id);
13046 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13047 (Some(expected), Some(provided)) if expected == provided => {}
13048 (None, None) => {}
13049 _ => {
13050 return Err(failure(
13051 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13052 "spawn lacks exact managed worktree lease authority",
13053 ));
13054 }
13055 }
13056 self.ensure_workspace_environment_profile_compatible(
13060 &workspace_id,
13061 environment_profile.as_ref(),
13062 )?;
13063 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13064 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13065 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13066 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13067 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13068 };
13069 let transport = match mode {
13075 SessionMode::Pty => TransportKind::Pty,
13076 SessionMode::Inline => TransportKind::Pipe,
13077 SessionMode::Acp => TransportKind::Acp,
13078 };
13079 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13080 let acp_transport = self.provider_supports_acp_transport(&provider);
13081 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13082 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13083 NodeFailureCode::UnsupportedSpawnCapability,
13084 "pre-admitted provider runtime no longer satisfies spawn requirements",
13085 ))?;
13086 runtime_policy
13087 } else if let Some(deadline) = deadline {
13088 let remaining = spawn_deadline_remaining(deadline)?;
13089 timeout(
13090 remaining,
13091 self.admit_provider_runtime(&provider, runtime_requirement),
13092 )
13093 .await
13094 .map_err(|_| {
13095 failure(
13096 NodeFailureCode::SpawnDeadlineExceeded,
13097 "provider admission exceeded the spawn deadline",
13098 )
13099 })??
13100 } else {
13101 self.admit_provider_runtime(&provider, runtime_requirement).await?
13102 };
13103 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13104 let runtime_policy =
13116 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13117 if required_capabilities
13118 .iter()
13119 .any(|capability| !runtime_policy.admits(*capability))
13120 {
13121 return Err(failure(
13122 NodeFailureCode::UnsupportedSpawnCapability,
13123 "provider runtime does not admit a required spawn capability",
13124 ));
13125 }
13126 if let Some(deadline) = deadline {
13127 spawn_deadline_remaining(deadline)?;
13128 }
13129 self.ensure_binding_capacity()?;
13130 let mut working_directory = self.workspace_root(&workspace_id)?;
13131 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13132 let session = SessionKey {
13133 instance_id,
13134 generation: SessionGeneration(1),
13135 };
13136 let address = SessionAddress {
13137 workspace_id: workspace_id.clone(),
13138 session,
13139 };
13140 tracing::info!(
13148 node_id = %self.node_id,
13149 workspace_id = %workspace_id,
13150 provider = %provider,
13151 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13152 session = ?address.session,
13153 "session spawn request accepted",
13154 );
13155 let mut prepared_spawn = SpawnPreparer::prepare(
13156 self,
13157 SpawnPrepareRequest {
13158 instance_id,
13159 address: &address,
13160 provider: &provider,
13161 mode,
13162 environment_profile: environment_profile.as_ref(),
13163 bundle: bundle.as_ref(),
13164 context: context.as_ref(),
13165 managed_authority: managed_authority.as_ref(),
13166 harness_mcp,
13167 approval_level,
13168 network_allowlist: network_allowlist.as_ref(),
13169 browser_profile_id,
13170 },
13171 )?;
13172 let materialization_id = prepared_spawn.materialization_id.take();
13173 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13174 let record_id = self.bind_spawn_session_with_materialization(
13175 &address,
13176 provider.clone(),
13177 mode,
13178 runtime_policy,
13179 record_policy,
13180 environment_profile,
13181 bundle,
13182 context,
13183 materialization_id,
13184 )?;
13185 prepared_spawn.retain_session_guards();
13186 let session_provider = provider.clone();
13190 let agent_id = provider;
13191 let register_command_id = match self.dispatch_bounded(
13192 ControlCommand::Register {
13193 instance_id,
13194 agent_id,
13195 transport,
13196 },
13197 dispatch_timeout,
13198 )
13199 .await {
13200 Ok(command_id) => command_id,
13201 Err(error) => {
13202 if let Some(binding) = self.remove_binding(&address) {
13203 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13204 }
13205 if let Some(record_id) = record_id.as_ref() {
13206 self.discard_record(record_id)?;
13207 }
13208 return Err(spawn_dispatch_error(error, deadline));
13209 }
13210 };
13211 let start_timeout = match spawn_dispatch_timeout(deadline) {
13212 Ok(timeout) => timeout,
13213 Err(error) => {
13214 let recovery = self
13215 .rollback_spawn(
13216 &address,
13217 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13218 )
13219 .await;
13220 return match recovery {
13221 Ok(()) => {
13222 if let Some(record_id) = record_id.as_ref() {
13223 self.discard_record(record_id)?;
13224 }
13225 Err(error)
13226 }
13227 Err(recovery_error) => {
13228 if let Some(record_id) = record_id.as_ref() {
13229 self.mark_record_error(
13230 record_id,
13231 "spawn deadline elapsed and registration recovery failed",
13232 )?;
13233 }
13234 Err(recovery_error)
13235 }
13236 };
13237 }
13238 };
13239 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13240 if let Err(recovery_error) = self
13241 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13242 .await
13243 {
13244 return Err(recovery_error);
13245 }
13246 if let Some(record_id) = record_id.as_ref() {
13247 self.discard_record(record_id)?;
13248 }
13249 return Err(start_error);
13250 }
13251 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13253 working_directory = dir.to_string_lossy().into_owned();
13254 }
13255 let start_result = self
13256 .dispatch_bounded(
13257 ControlCommand::Start {
13258 instance_id,
13259 runtime_policy,
13260 request: StartRequest {
13261 working_directory,
13262 terminal_size,
13263 initial_prompt,
13264 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13265 approval_level,
13266 },
13267 },
13268 start_timeout,
13269 )
13270 .await;
13271 let start_command_id = match start_result {
13272 Ok(command_id) => command_id,
13273 Err(start_error) => {
13274 let start_error = spawn_dispatch_error(start_error, deadline);
13275 let recovery = self
13276 .rollback_spawn(
13277 &address,
13278 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13279 )
13280 .await;
13281 return match recovery {
13282 Ok(()) => {
13283 if let Some(record_id) = record_id.as_ref() {
13284 self.discard_record(record_id)?;
13285 }
13286 Err(start_error)
13287 }
13288 Err(recovery_error) => {
13289 let diagnostic = format!(
13290 "start failed and registration recovery failed: {}",
13291 recovery_error.message,
13292 );
13293 if let Some(record_id) = record_id.as_ref() {
13294 self.mark_record_error(record_id, &diagnostic)?;
13295 }
13296 Err(failure(recovery_error.code, &diagnostic))
13297 }
13298 };
13299 }
13300 };
13301
13302 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13303 loop {
13304 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13313 .or_else(|| self.take_rejected_command_reason(start_command_id))
13314 {
13315 self.rollback_spawn(
13316 &address,
13317 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13318 ).await.map_err(|error| {
13319 failure(
13320 error.code,
13321 &format!(
13322 "spawn was rejected ({}) and registration recovery failed: {}",
13323 reason.message, error.message
13324 ),
13325 )
13326 })?;
13327 if let Some(record_id) = record_id.as_ref() {
13328 self.discard_record(record_id)?;
13329 }
13330 let code = if reason.unsupported_transport {
13331 NodeFailureCode::UnsupportedTransport
13332 } else {
13333 NodeFailureCode::BackendOperationFailed
13334 };
13335 return Err(failure(code, &reason.message));
13336 }
13337 if let Some(current) = self
13338 .handle
13339 .snapshot()
13340 .sessions
13341 .iter()
13342 .find(|current| current.instance_id == instance_id)
13343 {
13344 if current.generation != session.generation {
13345 let divergence = failure(
13346 NodeFailureCode::StaleGeneration,
13347 "spawn generation diverged",
13348 );
13349 return match self
13350 .rollback_spawn(
13351 &address,
13352 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13353 )
13354 .await
13355 {
13356 Ok(()) => {
13357 if let Some(record_id) = record_id.as_ref() {
13358 self.discard_record(record_id)?;
13359 }
13360 Err(divergence)
13361 }
13362 Err(recovery_error) => {
13363 if let Some(record_id) = record_id.as_ref() {
13364 self.mark_record_error(
13365 record_id,
13366 "spawn generation diverged and recovery failed",
13367 )?;
13368 }
13369 Err(recovery_error)
13370 }
13371 };
13372 }
13373 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13374 prepared_spawn.retain_launch_guards();
13375 return Ok((address, runtime_policy));
13376 }
13377 }
13378 if Instant::now() >= commit_deadline {
13379 self.rollback_spawn(
13380 &address,
13381 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13382 ).await.map_err(|error| {
13383 failure(
13384 error.code,
13385 &format!(
13386 "start did not commit and registration recovery failed: {}",
13387 error.message
13388 ),
13389 )
13390 })?;
13391 if let Some(record_id) = record_id.as_ref() {
13392 self.discard_record(record_id)?;
13393 }
13394 return Err(failure(
13395 if deadline.is_some() {
13396 NodeFailureCode::SpawnDeadlineExceeded
13397 } else {
13398 NodeFailureCode::BackendBusy
13399 },
13400 "start did not commit before the bounded deadline; registration was removed",
13401 ));
13402 }
13403 sleep(Duration::from_millis(2)).await;
13404 }
13405 }
13406
13407 fn effective_spawn_runtime_policy(
13408 &self,
13409 runtime_policy: ProviderRuntimePolicy,
13410 ) -> ProviderRuntimePolicy {
13411 #[cfg(feature = "fixture")]
13412 let runtime_policy = if self.fixture_semantic_hook_policy {
13413 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13414 .expect("monitoring Hook fixture policy is internally valid")
13415 } else {
13416 runtime_policy
13417 };
13418 runtime_policy
13419 }
13420
13421 fn verify_harness_mcp_before_start(
13422 &self,
13423 prepared: Option<&PreparedHarnessMcpSpawn>,
13424 ) -> Result<(), NodeFailure> {
13425 prepared
13426 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13427 .transpose()
13428 .map(|_| ())
13429 .map_err(harness_mcp_failure)
13430 }
13431
13432 async fn rollback_spawn(
13433 &self,
13434 address: &SessionAddress,
13435 commit_timeout: Duration,
13436 ) -> Result<(), NodeFailure> {
13437 self.dispatch_bounded(
13438 ControlCommand::Remove {
13439 instance_id: address.session.instance_id,
13440 },
13441 commit_timeout,
13442 )
13443 .await?;
13444 self.wait_until_removed(address, commit_timeout).await?;
13445 if let Some(binding) = self.remove_binding(address) {
13446 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13447 }
13448 Ok(())
13449 }
13450
13451 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13452 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13453 self.dispatch_bounded(
13454 ControlCommand::Remove {
13455 instance_id: address.session.instance_id,
13456 },
13457 commit_timeout,
13458 )
13459 .await?;
13460 self.wait_until_removed(address, commit_timeout).await?;
13461 let removed = self.remove_binding(address);
13462 if let Some(binding) = removed.as_ref() {
13463 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13464 }
13465 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13466 let snapshot = self.managed_worktrees.lock()
13467 .unwrap_or_else(|poisoned| poisoned.into_inner())
13468 .release_session(
13469 &lease_id,
13470 self.incarnation_id,
13471 address.session.instance_id,
13472 address.session.generation,
13473 unix_time_ms(),
13474 );
13475 if self.persist_state().is_err() {
13476 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13477 }
13478 if let Some(snapshot) = snapshot {
13479 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13480 }
13481 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13482 }
13483 Ok(())
13484 }
13485
13486 async fn wait_until_removed(
13532 &self,
13533 address: &SessionAddress,
13534 commit_timeout: Duration,
13535 ) -> Result<(), NodeFailure> {
13536 let deadline = Instant::now() + commit_timeout;
13537 let mut next_dispatch = Instant::now();
13538 loop {
13539 let snapshot = self.handle.snapshot();
13540 let status = snapshot
13541 .sessions
13542 .iter()
13543 .find(|session| session.instance_id == address.session.instance_id)
13544 .map(|session| session.status.clone());
13545 let Some(status) = status else {
13546 return Ok(());
13547 };
13548 let now = Instant::now();
13549 if now >= deadline {
13550 return Err(failure(
13551 NodeFailureCode::BackendBusy,
13552 "remove did not commit before the bounded deadline; session binding was retained",
13553 ));
13554 }
13555 if status.allows_remove() && now >= next_dispatch {
13556 let _ = self.dispatch(ControlCommand::Remove {
13557 instance_id: address.session.instance_id,
13558 });
13559 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13560 }
13561 sleep(Duration::from_millis(2)).await;
13562 }
13563 }
13564
13565 fn resync(&self, after_sequence: u64) -> NodeResponse {
13566 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13567 let event_sequence = history.last_sequence;
13568 let oldest_available_sequence = history.replay_floor_sequence;
13569 let events = history
13570 .events
13571 .iter()
13572 .filter(|event| event.sequence > after_sequence)
13573 .cloned()
13574 .collect();
13575 drop(history);
13576 NodeResponse::Resync {
13577 event_sequence,
13578 oldest_available_sequence,
13579 snapshot: self.snapshot(),
13580 events,
13581 }
13582 }
13583}
13584
13585fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13590 match kind {
13591 AgentStreamChunkKindV1::Text { .. } => "text",
13592 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13593 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13594 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13595 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13596 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13597 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13598 }
13599}
13600
13601async fn accept_connections(
13615 endpoint: &str,
13616 call_home: Option<std::net::SocketAddr>,
13617 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13618 shared: Arc<NodeShared>,
13619) -> Result<(), NodeServerError> {
13620 let mut connections = JoinSet::new();
13621 let result = match call_home {
13622 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13623 Some(relay) => {
13624 let listener =
13625 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13626 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13627 tokio::pin!(listener);
13628 tokio::pin!(dialler);
13629 tokio::select! {
13630 result = &mut listener => result,
13631 result = &mut dialler => result,
13632 }
13633 }
13634 };
13635 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13636 while !connections.is_empty() {
13637 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13638 if remaining.is_zero()
13639 || timeout(remaining, connections.join_next()).await.is_err()
13640 {
13641 break;
13642 }
13643 }
13644 connections.abort_all();
13645 while connections.join_next().await.is_some() {}
13646 result
13647}
13648
13649const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13653
13654async fn call_home_loop(
13668 relay: std::net::SocketAddr,
13669 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13670 shared: Arc<NodeShared>,
13671) -> Result<(), NodeServerError> {
13672 let mut failures = 0_usize;
13673 loop {
13674 if shared.shutdown.load(Ordering::Acquire) {
13675 return Ok(());
13676 }
13677 let permit = tokio::select! {
13678 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13679 permit.map_err(|_| NodeServerError::PreauthClosed)?
13680 }
13681 _ = shared.shutdown_notify.notified() => return Ok(()),
13682 };
13683 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13684 Ok(()) => {
13685 failures = 0;
13689 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13690 }
13691 Err(error) => {
13692 failures = failures.saturating_add(1);
13693 tracing::warn!(
13694 node_id = %shared.node_id,
13695 %relay,
13696 cause = %error,
13697 attempt = failures,
13698 "call-home connection failed",
13699 );
13700 let backoff = call_home_backoff(failures);
13701 tokio::select! {
13702 _ = sleep(backoff) => {}
13703 _ = shared.shutdown_notify.notified() => return Ok(()),
13704 }
13705 }
13706 }
13707 }
13708}
13709
13710const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13712const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13714
13715fn call_home_keepalive() -> socket2::TcpKeepalive {
13716 let keepalive = socket2::TcpKeepalive::new()
13717 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13718 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13719 #[cfg(not(windows))]
13720 let keepalive = keepalive.with_retries(3);
13721 keepalive
13722}
13723
13724fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13725 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13726}
13727
13728pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13730 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13731}
13732
13733fn call_home_backoff(attempt: usize) -> Duration {
13735 Duration::from_millis(
13736 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13737 )
13738}
13739
13740
13741async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13744 #[cfg(feature = "wireguard")]
13745 {
13746 let std_stream = tokio::task::spawn_blocking(move || {
13747 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13748 })
13749 .await
13750 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13751 .map_err(NodeServerError::CallHomeTransport)?;
13752 std_stream
13753 .set_nonblocking(true)
13754 .map_err(NodeServerError::CallHomeTransport)?;
13755 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13756 }
13757 #[cfg(not(feature = "wireguard"))]
13758 {
13759 tokio::net::TcpStream::connect(relay)
13760 .await
13761 .map_err(NodeServerError::CallHomeTransport)
13762 }
13763}
13764
13765async fn dial_relay_and_serve(
13767 relay: std::net::SocketAddr,
13768 identity: Option<&hatchery_enroll::NodeIdentity>,
13769 shared: Arc<NodeShared>,
13770 preauth_permit: OwnedSemaphorePermit,
13771) -> Result<(), NodeServerError> {
13772 let mut stream = dial_call_home(relay).await?;
13773 let _ = stream.set_nodelay(true);
13777 if let Err(error) = enable_call_home_keepalive(&stream) {
13783 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13784 }
13785 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13786 .await
13787 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13788 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13789 let mut session_token = None;
13790 if let Some(identity) = identity {
13791 let mut magic = [0_u8; 4];
13795 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13796 .await
13797 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13798 .map_err(NodeServerError::CallHomeTransport)?;
13799 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13800 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13801 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13802 .await
13803 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13804 .map_err(NodeServerError::CallHomeTransport)?;
13805 let (answer, token) = identity
13806 .answer_challenge(shared.node_id.as_str(), &frame)
13807 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13808 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13809 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13810 session_token = Some(token);
13811 }
13812 }
13813 serve_connection(stream, shared, session_token, preauth_permit).await
13814}
13815
13816async fn accept_connections_inner(
13817 endpoint: &str,
13818 shared: Arc<NodeShared>,
13819 connections: &mut JoinSet<Result<(), NodeServerError>>,
13820) -> Result<(), NodeServerError> {
13821 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13822 loop {
13823 while connections.try_join_next().is_some() {}
13824 if shared.shutdown.load(Ordering::Acquire) {
13825 return Ok(());
13826 }
13827 let preauth_permit = tokio::select! {
13828 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13829 permit.map_err(|_| NodeServerError::PreauthClosed)?
13830 }
13831 _ = shared.shutdown_notify.notified() => return Ok(()),
13832 };
13833 let server = tokio::select! {
13834 result = listener.accept() => result?,
13835 _ = shared.shutdown_notify.notified() => return Ok(()),
13836 };
13837 let connection_shared = Arc::clone(&shared);
13838 connections.spawn(async move {
13839 serve_connection(server, connection_shared, None, preauth_permit).await
13840 });
13841 if shared.shutdown.load(Ordering::Acquire) {
13842 return Ok(());
13843 }
13844 }
13845}
13846
13847async fn serve_connection<S>(
13855 mut pipe: S,
13856 shared: Arc<NodeShared>,
13857 session_token: Option<String>,
13858 preauth_permit: OwnedSemaphorePermit,
13859) -> Result<(), NodeServerError>
13860where
13861 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13862{
13863 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13865 let hello_frame = timeout(
13866 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13867 read_json_frame_limited_body_timeout(
13868 &mut pipe,
13869 MAX_NODE_HELLO_FRAME_BYTES,
13870 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13871 ),
13872 )
13873 .await
13874 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13875 let ClientFrame::Hello(hello) = hello_frame else {
13876 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13877 };
13878 if hello.build_stamp != BUILD_STAMP {
13879 return Err(NodeServerError::Handshake(format!(
13880 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13881 hello.build_stamp,
13882 )));
13883 }
13884 let compatibility = match hello.compatibility.as_ref() {
13885 Some(offer) => {
13886 let mut selected = node_compatibility_support(&shared)?
13887 .negotiate(offer)
13888 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13889 project_negotiated_provider_ids(&mut selected);
13890 Some(selected)
13891 }
13892 None => None,
13893 };
13894 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13895 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13896 (Some(offer), Some(selected)) => negotiated_auth_proof(
13897 auth_token,
13898 AuthDirection::Server,
13899 hello.role,
13900 &hello.client_nonce,
13901 &server_nonce,
13902 offer,
13903 selected,
13904 ),
13905 (None, None) => auth_proof(
13906 auth_token,
13907 AuthDirection::Server,
13908 hello.role,
13909 &hello.client_nonce,
13910 &server_nonce,
13911 ),
13912 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13913 }
13914 .map_err(NodeServerError::Authentication)?;
13915 write_json_frame_limited(
13916 &mut pipe,
13917 &ServerFrame::Challenge(ServerChallenge {
13918 build_stamp: BUILD_STAMP.to_owned(),
13919 server_nonce,
13920 server_proof,
13921 compatibility: compatibility.clone(),
13922 }),
13923 MAX_NODE_HELLO_FRAME_BYTES,
13924 )
13925 .await?;
13926 let authentication = timeout(
13927 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13928 read_json_frame_limited_body_timeout(
13929 &mut pipe,
13930 MAX_NODE_HELLO_FRAME_BYTES,
13931 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13932 ),
13933 )
13934 .await
13935 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13936 let ClientFrame::Authenticate(authentication) = authentication else {
13937 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13938 };
13939 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13940 (Some(offer), Some(selected)) => negotiated_auth_proof(
13941 auth_token,
13942 AuthDirection::Client,
13943 hello.role,
13944 &hello.client_nonce,
13945 &server_nonce,
13946 offer,
13947 selected,
13948 ),
13949 (None, None) => auth_proof(
13950 auth_token,
13951 AuthDirection::Client,
13952 hello.role,
13953 &hello.client_nonce,
13954 &server_nonce,
13955 ),
13956 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13957 }
13958 .map_err(NodeServerError::Authentication)?;
13959 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13960 return Err(NodeServerError::Handshake("access denied".to_owned()));
13961 }
13962 let selected_capabilities = compatibility
13963 .as_ref()
13964 .map(|selected| selected.capabilities.clone())
13965 .unwrap_or_default();
13966 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13967 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13968 });
13969 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13970 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13971 });
13972 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13973 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13974 });
13975 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13976 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13977 });
13978 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13979 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13980 });
13981 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13982 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13983 }) && selected_capabilities.iter().any(|capability| {
13984 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13985 });
13986 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13987 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13988 });
13989 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13990 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13991 });
13992 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13993 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13994 });
13995 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13996 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13997 });
13998 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13999 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14000 });
14001 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14002 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14003 });
14004 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14005 .try_acquire_owned()
14006 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14007 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14008 let _release_guard = ControllerReleaseGuard {
14009 shared: Arc::clone(&shared),
14010 connection_id,
14011 };
14012 drop(preauth_permit);
14013 let _authenticated_permit = authenticated_permit;
14014 let mut event_rx = shared.event_tx.subscribe();
14015 let mut terminal_event_rx = terminal_event_subscription(
14016 &shared,
14017 include_terminal_frame_events,
14018 );
14019 let mut agent_stream_event_rx = agent_stream_event_subscription(
14020 &shared,
14021 include_agent_stream_events,
14022 );
14023 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14024 &shared,
14025 include_harness_mcp_proxy,
14026 );
14027 write_json_frame(
14028 &mut pipe,
14029 &ServerFrame::Hello(NodeHello {
14030 build_stamp: BUILD_STAMP.to_owned(),
14031 incarnation_id: shared.incarnation_id,
14032 connection_id,
14033 role: hello.role,
14034 event_sequence: shared.current_sequence(),
14035 controller: shared.controller_state(),
14036 snapshot: snapshot_for_wire(
14037 &shared,
14038 include_provider_runtime_status,
14039 include_open_provider_ids,
14040 include_spawn_profiles,
14041 include_managed_worktrees,
14042 include_child_environment_profiles,
14043 include_session_bundles,
14044 include_history_context_packs,
14045 include_agent_progress,
14046 include_session_task_correlation,
14047 ),
14048 compatibility,
14049 }),
14050 )
14051 .await?;
14052 let (mut reader, mut writer) = tokio::io::split(pipe);
14053 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14054 let reader_task = tokio::spawn(async move {
14055 loop {
14056 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14057 &mut reader,
14058 MAX_NODE_CLIENT_FRAME_BYTES,
14059 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14060 )
14061 .await;
14062 let terminal = frame.is_err();
14063 if frame_tx.send(frame).await.is_err() || terminal {
14064 break;
14065 }
14066 }
14067 });
14068 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14069 let mut pending_events = Vec::new();
14070 let mut resync_required = false;
14071 let mut discard_events_through = 0_u64;
14072 loop {
14073 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14078 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14079 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14080 for _ in 0..durable_drain_budget {
14081 match event_rx.try_recv() {
14082 Ok(event) => queue_connection_event(
14083 &mut pending_events,
14084 event,
14085 discard_events_through,
14086 ),
14087 Err(broadcast::error::TryRecvError::Empty) => break,
14088 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14089 resync_required = true;
14090 break;
14091 }
14092 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14093 }
14094 }
14095 if let Some(receiver) = terminal_event_rx.as_mut() {
14096 match receiver.try_recv() {
14097 Ok(events) => queue_connection_event_batch(
14098 &mut pending_events,
14099 &events,
14100 discard_events_through,
14101 ),
14102 Err(broadcast::error::TryRecvError::Empty) => {}
14103 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14104 resync_required = true;
14105 }
14106 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14107 }
14108 }
14109 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14110 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14120 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14121 for _ in 0..agent_stream_drain_budget {
14122 match receiver.try_recv() {
14123 Ok(events) => queue_connection_event_batch(
14124 &mut pending_events,
14125 &events,
14126 discard_events_through,
14127 ),
14128 Err(broadcast::error::TryRecvError::Empty) => break,
14129 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14130 tracing::warn!(
14131 connection_id,
14132 dropped,
14133 "agent stream event subscription lagged; forcing a resync",
14134 );
14135 resync_required = true;
14136 break;
14137 }
14138 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14139 }
14140 }
14141 }
14142 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14143 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14149 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14150 for _ in 0..harness_mcp_proxy_drain_budget {
14151 match receiver.try_recv() {
14152 Ok(events) => queue_connection_event_batch(
14153 &mut pending_events,
14154 &events,
14155 discard_events_through,
14156 ),
14157 Err(broadcast::error::TryRecvError::Empty) => break,
14158 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14159 tracing::warn!(
14160 connection_id,
14161 dropped,
14162 "harness MCP proxy event subscription lagged; forcing a resync",
14163 );
14164 resync_required = true;
14165 break;
14166 }
14167 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14168 }
14169 }
14170 }
14171 if resync_required {
14172 let event = resync_required_event(&shared);
14173 apply_connection_resync_watermark(
14174 &mut pending_events,
14175 &mut discard_events_through,
14176 event.sequence,
14177 );
14178 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14179 resync_required = false;
14180 continue;
14181 }
14182 if !pending_events.is_empty() {
14183 pending_events.sort_unstable_by_key(|event| event.sequence);
14184 let burst_len = connection_event_burst_len(pending_events.len());
14185 shared
14186 .connection_events_sent
14187 .fetch_add(burst_len as u64, Ordering::Relaxed);
14188 for event in pending_events.drain(..burst_len) {
14189 let event = if include_managed_worktrees {
14190 Some(event)
14191 } else {
14192 project_event_without_managed_worktrees(event)
14193 };
14194 let event = event.and_then(|event| if include_open_provider_ids {
14195 Some(event)
14196 } else {
14197 project_event_legacy_provider_ids(&shared, event)
14198 });
14199 let event = event.map(|event| if include_child_environment_profiles {
14200 event
14201 } else {
14202 project_event_without_child_environment_profile(event)
14203 });
14204 let event = event.map(|event| if include_session_bundles {
14205 event
14206 } else {
14207 project_event_without_session_bundle(event)
14208 });
14209 let event = event.map(|event| {
14210 project_event_history_for_wire(event, include_history_context_packs)
14211 });
14212 let event = event.map(|event| if include_history_context_packs {
14213 event
14214 } else {
14215 project_event_without_context_pack(event)
14216 });
14217 let event = event.map(|event| if include_session_task_correlation {
14218 event
14219 } else {
14220 project_event_without_session_task_binding(event)
14221 });
14222 let exact_harness_controller = hello.role == ClientRole::Operator
14223 && shared.controller_state().is_some_and(|controller| {
14224 controller.connection_id == connection_id
14225 });
14226 let event = event.and_then(|event| {
14227 if include_harness_mcp_proxy && exact_harness_controller {
14228 Some(event)
14229 } else {
14230 project_event_without_harness_mcp_proxy(event)
14231 }
14232 });
14233 if let Some(event) = event {
14234 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14235 }
14236 }
14237 }
14238 tokio::select! {
14239 biased;
14240 frame = frame_rx.recv() => {
14241 let frame = match frame {
14242 Some(frame) => frame,
14243 None => break,
14244 };
14245 let frame = match frame {
14246 Ok(frame) => frame,
14247 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14248 Err(error) => return Err(error.into()),
14249 };
14250 let ClientFrame::Request(request) = frame else {
14251 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14252 };
14253 #[cfg(feature = "fixture")]
14254 let probe_spawn_managed_worktree_v2 = matches!(
14255 &request.request,
14256 NodeRequest::SpawnManagedWorktreeV2 { .. }
14257 );
14258 let requires_open_provider_ids =
14259 request_requires_open_provider_ids(&shared, &request.request);
14260 let requires_child_environment_profile =
14261 request_requires_child_environment_profile(&shared, &request.request);
14262 let requires_session_bundle =
14263 request_requires_session_bundle(&shared, &request.request);
14264 let requires_history_context_pack =
14265 request_requires_history_context_pack(&shared, &request.request);
14266 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14267 ResponseEnvelope {
14268 request_id: request.request_id,
14269 result: Err(failure(
14270 NodeFailureCode::UnsupportedCapability,
14271 "open provider IDs were not negotiated",
14272 )),
14273 }
14274 } else if requires_child_environment_profile
14275 && !include_child_environment_profiles
14276 {
14277 ResponseEnvelope {
14278 request_id: request.request_id,
14279 result: Err(failure(
14280 NodeFailureCode::UnsupportedCapability,
14281 "child environment profiles were not negotiated",
14282 )),
14283 }
14284 } else if requires_session_bundle && !include_session_bundles {
14285 ResponseEnvelope {
14286 request_id: request.request_id,
14287 result: Err(failure(
14288 NodeFailureCode::UnsupportedCapability,
14289 "session bundles were not negotiated",
14290 )),
14291 }
14292 } else if requires_history_context_pack && !include_history_context_packs {
14293 ResponseEnvelope {
14294 request_id: request.request_id,
14295 result: Err(failure(
14296 NodeFailureCode::UnsupportedCapability,
14297 "history context packs were not negotiated",
14298 )),
14299 }
14300 } else if request_uses_unnegotiated_capability(
14301 &request.request,
14302 &selected_capabilities,
14303 )
14304 {
14305 ResponseEnvelope {
14306 request_id: request.request_id,
14307 result: Err(failure(
14308 NodeFailureCode::UnsupportedCapability,
14309 "request capability was not negotiated",
14310 )),
14311 }
14312 } else {
14313 process_request(&shared, connection_id, hello.role, request).await
14314 };
14315 #[cfg(feature = "fixture")]
14316 if probe_spawn_managed_worktree_v2 {
14317 shared
14318 .fixture_spawn_managed_worktree_v2_failure_probe
14319 .record(reply.result.as_ref().err().map(|failure| failure.code));
14320 }
14321 if !include_provider_runtime_status {
14322 clear_response_provider_runtime_status(&mut reply);
14323 }
14324 if !include_managed_worktrees {
14325 project_response_without_managed_worktrees(&mut reply);
14326 }
14327 if !include_open_provider_ids {
14328 project_response_legacy_provider_ids(&shared, &mut reply);
14329 }
14330 if !include_child_environment_profiles {
14331 project_response_without_child_environment_profile(&mut reply);
14332 }
14333 if !include_session_bundles {
14334 project_response_without_session_bundle(&mut reply);
14335 }
14336 project_response_history_for_wire(
14337 &mut reply,
14338 include_history_context_packs,
14339 );
14340 if !include_history_context_packs {
14341 project_response_without_context_pack(&mut reply);
14342 }
14343 if !include_agent_progress {
14344 project_response_without_agent_progress(&mut reply);
14345 }
14346 if !include_session_task_correlation {
14347 project_response_without_session_task_binding(&mut reply);
14348 }
14349 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14350 }
14351 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14352 event = event_rx.recv() => {
14353 match event {
14354 Ok(event) => queue_connection_event(
14355 &mut pending_events,
14356 event,
14357 discard_events_through,
14358 ),
14359 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14360 Err(broadcast::error::RecvError::Closed) => break,
14361 }
14362 }
14363 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14364 match events {
14365 Ok(events) => queue_connection_event_batch(
14366 &mut pending_events,
14367 &events,
14368 discard_events_through,
14369 ),
14370 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14371 Err(broadcast::error::RecvError::Closed) => break,
14372 }
14373 }
14374 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14375 match events {
14376 Ok(events) => queue_connection_event_batch(
14377 &mut pending_events,
14378 &events,
14379 discard_events_through,
14380 ),
14381 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14382 Err(broadcast::error::RecvError::Closed) => break,
14383 }
14384 }
14385 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14386 match events {
14387 Ok(events) => queue_connection_event_batch(
14388 &mut pending_events,
14389 &events,
14390 discard_events_through,
14391 ),
14392 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14393 Err(broadcast::error::RecvError::Closed) => break,
14394 }
14395 }
14396 _ = shared.shutdown_notify.notified() => break,
14397 }
14398 }
14399 Ok(())
14400}
14401
14402async fn receive_terminal_event_batch(
14403 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14404) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14405 match receiver {
14406 Some(receiver) => receiver.recv().await,
14407 None => std::future::pending().await,
14408 }
14409}
14410
14411fn terminal_event_subscription(
14412 shared: &NodeShared,
14413 enabled: bool,
14414) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14415 enabled.then(|| shared.terminal_event_tx.subscribe())
14416}
14417
14418fn agent_stream_event_subscription(
14422 shared: &NodeShared,
14423 enabled: bool,
14424) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14425 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14426}
14427
14428fn harness_mcp_proxy_event_subscription(
14433 shared: &NodeShared,
14434 enabled: bool,
14435) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14436 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14437}
14438
14439fn queue_connection_event(
14440 pending: &mut Vec<NodeEventEnvelope>,
14441 event: NodeEventEnvelope,
14442 discard_through: u64,
14443) {
14444 if event.sequence <= discard_through
14445 && !event.event.requires_harness_mcp_proxy_capability()
14446 {
14447 return;
14448 }
14449 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14450 if let Some(index) = pending.iter().position(|current| {
14451 matches!(
14452 ¤t.event,
14453 NodeEvent::TerminalFrame {
14454 address: current_address,
14455 ..
14456 } if current_address == address
14457 )
14458 }) {
14459 pending.remove(index);
14460 }
14461 }
14462 pending.push(event);
14463}
14464
14465fn project_event_without_harness_mcp_proxy(
14466 envelope: NodeEventEnvelope,
14467) -> Option<NodeEventEnvelope> {
14468 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14469}
14470
14471fn queue_connection_event_batch(
14472 pending: &mut Vec<NodeEventEnvelope>,
14473 events: &[NodeEventEnvelope],
14474 discard_through: u64,
14475) {
14476 for event in events {
14477 queue_connection_event(pending, event.clone(), discard_through);
14478 }
14479}
14480
14481fn apply_connection_resync_watermark(
14482 pending: &mut Vec<NodeEventEnvelope>,
14483 discard_through: &mut u64,
14484 marker_sequence: u64,
14485) {
14486 *discard_through = (*discard_through).max(marker_sequence);
14487 pending.retain(|event| event.sequence > *discard_through);
14488}
14489
14490fn connection_event_burst_len(pending_len: usize) -> usize {
14491 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14492}
14493
14494fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14495 let history = shared
14496 .history
14497 .lock()
14498 .unwrap_or_else(|poisoned| poisoned.into_inner());
14499 let sequence = history.last_sequence;
14500 let oldest_available_sequence = history.replay_floor_sequence;
14501 NodeEventEnvelope {
14502 sequence,
14503 event: NodeEvent::ResyncRequired {
14504 oldest_available_sequence,
14505 },
14506 }
14507}
14508
14509fn node_compatibility_support(
14510 shared: &NodeShared,
14511) -> Result<NodeCompatibilitySupport, NodeServerError> {
14512 let mut support = node_compatibility_support_for_manifest(
14513 &shared.provider_contracts,
14514 &shared.provider_adapter_contracts,
14515 )?;
14516 if shared
14517 .delivery_store
14518 .lock()
14519 .unwrap_or_else(|poisoned| poisoned.into_inner())
14520 .is_some()
14521 {
14522 support.capabilities.push(
14523 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14524 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525 );
14526 }
14527 if shared.harness_mcp_registry.is_some() {
14528 support.capabilities.push(
14529 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14530 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14531 );
14532 }
14533 Ok(support)
14534}
14535
14536fn node_compatibility_support_for_manifest(
14537 provider_contracts: &[ProviderContractSupport],
14538 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14539) -> Result<NodeCompatibilitySupport, NodeServerError> {
14540 Ok(NodeCompatibilitySupport {
14541 build_stamp: BUILD_STAMP.to_owned(),
14542 capabilities: baseline_capabilities()?,
14543 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14544 path_semantics: platform::path_semantics(),
14545 local_transport: platform::local_transport(),
14546 state_schema: StateSchemaSupport {
14547 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14548 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14549 },
14550 provider_contracts: provider_contracts.to_vec(),
14551 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14552 })
14553}
14554
14555fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14556 let capabilities = [
14557 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14558 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14559 NODE_REPOSITORY_PATH_CAPABILITY,
14560 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14561 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14562 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14563 NODE_GIT_READ_CAPABILITY,
14564 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14565 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14566 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14567 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14568 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14569 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14570 NODE_ACP_CONTROL_CAPABILITY,
14571 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14572 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14573 NODE_WORKTREE_SELECTION_CAPABILITY,
14574 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14575 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14576 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14577 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14578 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14579 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14580 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14581 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14582 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14583 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14584 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14585 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14586 ]
14587 .into_iter()
14588 .map(|capability| {
14589 CapabilityId::new(capability)
14590 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14591 })
14592 .collect::<Result<Vec<_>, _>>()?;
14593 #[cfg(windows)]
14594 let capabilities = {
14595 let mut capabilities = capabilities;
14596 capabilities.push(
14597 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14598 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14599 );
14600 capabilities
14601 };
14602 Ok(capabilities)
14603}
14604
14605fn request_uses_unnegotiated_capability(
14606 request: &NodeRequest,
14607 selected_capabilities: &[CapabilityId],
14608) -> bool {
14609 let selected = |required: &str| {
14610 selected_capabilities
14611 .iter()
14612 .any(|capability| capability.as_str() == required)
14613 };
14614 request
14615 .required_capability()
14616 .is_some_and(|required| !selected(required))
14617 || (request.requires_worktree_selection_capability()
14618 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14619 || (request.requires_spawn_spec_defaults_overrides_capability()
14620 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14621 || (request.requires_spawn_profile_revision_capability()
14622 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14623 || (request.requires_child_environment_profile_capability()
14624 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14625 || (request.requires_session_bundle_materialization_capability()
14626 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14627 || (request.requires_history_context_pack_capability()
14628 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14629}
14630
14631fn snapshot_for_wire(
14632 shared: &NodeShared,
14633 include_provider_runtime_status: bool,
14634 include_open_provider_ids: bool,
14635 include_spawn_profiles: bool,
14636 include_managed_worktrees: bool,
14637 include_child_environment_profiles: bool,
14638 include_session_bundles: bool,
14639 include_history_context_packs: bool,
14640 include_agent_progress: bool,
14641 include_session_task_correlation: bool,
14642) -> NodeSnapshot {
14643 let mut snapshot = shared.snapshot();
14644 if !include_provider_runtime_status {
14645 snapshot.provider_runtime_statuses.clear();
14646 }
14647 if !include_open_provider_ids {
14648 project_snapshot_legacy_provider_ids(&mut snapshot);
14649 }
14650 if !include_managed_worktrees {
14651 snapshot.managed_worktrees.clear();
14652 for workspace in &mut snapshot.workspaces {
14653 workspace.managed_worktree_profiles = None;
14654 }
14655 }
14656 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14657 if !include_spawn_profiles {
14658 inventory.spawn_profiles = None;
14659 }
14660 if !include_session_bundles {
14661 inventory.bundles = None;
14662 }
14663 if inventory.spawn_profiles.is_none()
14664 && inventory.bundles.is_none()
14665 && inventory.network_allowlists.is_none()
14666 {
14667 snapshot.launch_inventory = None;
14668 }
14669 }
14670 if !include_child_environment_profiles {
14671 clear_snapshot_child_environment_profiles(&mut snapshot);
14672 }
14673 if !include_session_bundles {
14674 clear_snapshot_session_bundles(&mut snapshot);
14675 }
14676 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14677 if !include_history_context_packs {
14678 clear_snapshot_context_packs(&mut snapshot);
14679 }
14680 if !include_agent_progress {
14681 snapshot.agent_progress.clear();
14682 }
14683 if !include_session_task_correlation {
14684 clear_snapshot_session_task_bindings(&mut snapshot);
14685 }
14686 snapshot
14687}
14688
14689fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14690 for record in &mut snapshot.session_records {
14691 record.task_binding = None;
14692 }
14693}
14694
14695fn project_event_without_session_task_binding(
14696 mut envelope: NodeEventEnvelope,
14697) -> NodeEventEnvelope {
14698 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14699 record.task_binding = None;
14700 }
14701 envelope
14702}
14703
14704fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14705 let Ok(response) = reply.result.as_mut() else { return };
14706 match response {
14707 NodeResponse::Snapshot { snapshot, .. } => {
14708 clear_snapshot_session_task_bindings(snapshot);
14709 }
14710 NodeResponse::Resync { snapshot, events, .. } => {
14711 clear_snapshot_session_task_bindings(snapshot);
14712 for event in events {
14713 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14714 record.task_binding = None;
14715 }
14716 }
14717 }
14718 NodeResponse::SessionRecordUpdated { record }
14719 | NodeResponse::ProviderSessionIndexed { record }
14720 | NodeResponse::NativeSessionIndexed { record, .. }
14721 | NodeResponse::SessionRecordResumed { record, .. } => {
14722 record.task_binding = None;
14723 }
14724 _ => {}
14725 }
14726}
14727
14728fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14729 let Ok(response) = reply.result.as_mut() else { return };
14730 match response {
14731 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14732 snapshot.agent_progress.clear();
14733 }
14734 _ => {}
14735 }
14736}
14737
14738fn project_event_without_managed_worktrees(
14739 mut envelope: NodeEventEnvelope,
14740) -> Option<NodeEventEnvelope> {
14741 if matches!(
14742 envelope.event,
14743 NodeEvent::ManagedWorktreeUpserted { .. }
14744 | NodeEvent::ManagedWorktreeRemoved { .. }
14745 ) {
14746 return None;
14747 }
14748 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14749 workspace.managed_worktree_profiles = None;
14750 }
14751 Some(envelope)
14752}
14753
14754fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14755 if matches!(
14756 reply.result,
14757 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14758 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14759 ) {
14760 reply.result = Err(failure(
14761 NodeFailureCode::UnsupportedCapability,
14762 "managed worktree capability was not negotiated",
14763 ));
14764 return;
14765 }
14766 let Ok(response) = reply.result.as_mut() else { return };
14767 match response {
14768 NodeResponse::Snapshot { snapshot, .. } => {
14769 snapshot.managed_worktrees.clear();
14770 for workspace in &mut snapshot.workspaces {
14771 workspace.managed_worktree_profiles = None;
14772 }
14773 }
14774 NodeResponse::Resync { snapshot, events, .. } => {
14775 snapshot.managed_worktrees.clear();
14776 for workspace in &mut snapshot.workspaces {
14777 workspace.managed_worktree_profiles = None;
14778 }
14779 *events = std::mem::take(events)
14780 .into_iter()
14781 .filter_map(project_event_without_managed_worktrees)
14782 .collect();
14783 }
14784 NodeResponse::WorkspaceRegistered { workspace }
14785 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14786 | NodeResponse::WorktreeCreated { workspace, .. } => {
14787 workspace.managed_worktree_profiles = None;
14788 }
14789 NodeResponse::WorkspaceInspected { inspection } => {
14790 inspection.git.managed_worktree = None;
14791 }
14792 _ => {}
14793 }
14794}
14795
14796fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14797 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14798 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14799 });
14800 if includes_open_provider_ids {
14801 return;
14802 }
14803 compatibility
14804 .provider_contracts
14805 .retain(|contract| provider_id_is_legacy(&contract.provider));
14806 compatibility
14807 .provider_adapter_contracts
14808 .retain(|contract| provider_id_is_legacy(&contract.provider));
14809}
14810
14811fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14812 if let Some(spec) = match request {
14813 NodeRequest::SpawnSpec { spec }
14814 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14815 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14816 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14817 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14818 _ => None,
14819 } {
14820 return shared
14821 .resolve_spawn_spec(spec)
14822 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14823 }
14824 request_requires_open_provider_ids_with(
14825 request,
14826 |session| shared.validate_address(session).ok(),
14827 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14828 )
14829}
14830
14831fn request_requires_child_environment_profile(
14832 shared: &NodeShared,
14833 request: &NodeRequest,
14834) -> bool {
14835 if let Some(spec) = match request {
14836 NodeRequest::SpawnSpec { spec }
14837 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14838 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14839 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14840 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14841 _ => None,
14842 } {
14843 return shared
14844 .resolve_spawn_spec(spec)
14845 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14846 }
14847 match request {
14848 NodeRequest::Resume { session, .. }
14849 | NodeRequest::Prompt { session, .. }
14850 | NodeRequest::Paste { session, .. }
14851 | NodeRequest::Input { session, .. }
14852 | NodeRequest::TerminalBytes { session, .. }
14853 | NodeRequest::TerminalControl { session, .. }
14854 | NodeRequest::Resize { session, .. }
14855 | NodeRequest::Interrupt { session }
14856 | NodeRequest::Stop { session, .. }
14857 | NodeRequest::Remove { session }
14858 | NodeRequest::ResolveInteraction { session, .. }
14859 | NodeRequest::SetSessionMode { session, .. }
14860 | NodeRequest::SetSessionConfigOption { session, .. }
14861 | NodeRequest::SetSessionModel { session, .. }
14862 | NodeRequest::DiscoverHistory { session, .. }
14863 | NodeRequest::LoadHistory { session, .. }
14864 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14865 | NodeRequest::ExportContextPack { session } => shared
14866 .session_bindings
14867 .lock()
14868 .unwrap_or_else(|poisoned| poisoned.into_inner())
14869 .get(&session.session.instance_id)
14870 .filter(|binding| {
14871 binding.workspace_id == session.workspace_id
14872 && binding.generation == session.session.generation
14873 })
14874 .map_or(true, |binding| binding.environment_profile.is_some()),
14875 NodeRequest::RenameSessionRecord { record_id, .. }
14876 | NodeRequest::SetSessionTask { record_id, .. }
14877 | NodeRequest::ResumeSessionRecord { record_id, .. }
14878 | NodeRequest::ForgetSessionRecord { record_id } => shared
14879 .record(record_id)
14880 .map_or(true, |record| record.environment_profile.is_some()),
14881 NodeRequest::ArmHarnessMcpReservation { .. }
14882 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14883 | NodeRequest::ActivateHarnessMcpReservation { .. }
14884 | NodeRequest::AbortHarnessMcpReservation { .. }
14885 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14886 | NodeRequest::RejectHarnessMcpCall { .. }
14887 | NodeRequest::Snapshot
14888 | NodeRequest::Resync { .. }
14889 | NodeRequest::BeginDeliveryStage { .. }
14890 | NodeRequest::PutDeliveryBlobChunk { .. }
14891 | NodeRequest::CommitDeliveryStage { .. }
14892 | NodeRequest::AbortDeliveryStage { .. }
14893 | NodeRequest::BrowseHostDirectories { .. }
14894 | NodeRequest::InspectWorkspace { .. }
14895 | NodeRequest::ReadWorkspaceFile { .. }
14896 | NodeRequest::WriteWorkspaceFile { .. }
14897 | NodeRequest::CreateWorkspaceFile { .. }
14898 | NodeRequest::CreateWorkspaceDirectory { .. }
14899 | NodeRequest::ReadGitHistory { .. }
14900 | NodeRequest::ReadGitDiff { .. }
14901 | NodeRequest::AcquireController { .. }
14902 | NodeRequest::ReleaseController
14903 | NodeRequest::Spawn { .. }
14904 | NodeRequest::SpawnWithOptions { .. }
14905 | NodeRequest::SpawnSpec { .. }
14906 | NodeRequest::RegisterWorkspace { .. }
14907 | NodeRequest::CreateStandaloneWorkspace { .. }
14908 | NodeRequest::UnregisterWorkspace { .. }
14909 | NodeRequest::CreateWorktree { .. }
14910 | NodeRequest::RemoveWorktree { .. }
14911 | NodeRequest::SpawnManagedWorktree { .. }
14912 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14913 | NodeRequest::CleanupManagedWorktree { .. }
14914 | NodeRequest::IndexProviderSession { .. }
14915 | NodeRequest::IndexNativeSession { .. }
14916 | NodeRequest::CatalogNativeSessions { .. }
14917 | NodeRequest::PageNativeSessions { .. }
14918 | NodeRequest::PreviewNativeSession { .. }
14919 | NodeRequest::PreviewSessionRecord { .. }
14920 | NodeRequest::ForgetContextPack { .. }
14921 | NodeRequest::ResolveDurableContextPack { .. }
14922 | NodeRequest::ReadContextPack { .. }
14923 | NodeRequest::Shutdown => false,
14924 }
14925}
14926
14927fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14928 if let Some(spec) = match request {
14929 NodeRequest::SpawnSpec { spec }
14930 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14931 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14932 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14933 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14934 _ => None,
14935 } {
14936 return shared
14937 .resolve_spawn_spec(spec)
14938 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14939 }
14940 match request {
14941 NodeRequest::Resume { session, .. }
14942 | NodeRequest::Prompt { session, .. }
14943 | NodeRequest::Paste { session, .. }
14944 | NodeRequest::Input { session, .. }
14945 | NodeRequest::TerminalBytes { session, .. }
14946 | NodeRequest::TerminalControl { session, .. }
14947 | NodeRequest::Resize { session, .. }
14948 | NodeRequest::Interrupt { session }
14949 | NodeRequest::Stop { session, .. }
14950 | NodeRequest::Remove { session }
14951 | NodeRequest::ResolveInteraction { session, .. }
14952 | NodeRequest::SetSessionMode { session, .. }
14953 | NodeRequest::SetSessionConfigOption { session, .. }
14954 | NodeRequest::SetSessionModel { session, .. }
14955 | NodeRequest::DiscoverHistory { session, .. }
14956 | NodeRequest::LoadHistory { session, .. }
14957 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14958 | NodeRequest::ExportContextPack { session } => shared
14959 .session_bindings
14960 .lock()
14961 .unwrap_or_else(|poisoned| poisoned.into_inner())
14962 .get(&session.session.instance_id)
14963 .filter(|binding| {
14964 binding.workspace_id == session.workspace_id
14965 && binding.generation == session.session.generation
14966 })
14967 .map_or(true, |binding| binding.bundle.is_some()),
14968 NodeRequest::RenameSessionRecord { record_id, .. }
14969 | NodeRequest::SetSessionTask { record_id, .. }
14970 | NodeRequest::ResumeSessionRecord { record_id, .. }
14971 | NodeRequest::ForgetSessionRecord { record_id } => shared
14972 .record(record_id)
14973 .map_or(true, |record| record.bundle.is_some()),
14974 NodeRequest::ArmHarnessMcpReservation { .. }
14975 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14976 | NodeRequest::ActivateHarnessMcpReservation { .. }
14977 | NodeRequest::AbortHarnessMcpReservation { .. }
14978 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14979 | NodeRequest::RejectHarnessMcpCall { .. }
14980 | NodeRequest::Snapshot
14981 | NodeRequest::Resync { .. }
14982 | NodeRequest::BeginDeliveryStage { .. }
14983 | NodeRequest::PutDeliveryBlobChunk { .. }
14984 | NodeRequest::CommitDeliveryStage { .. }
14985 | NodeRequest::AbortDeliveryStage { .. }
14986 | NodeRequest::BrowseHostDirectories { .. }
14987 | NodeRequest::InspectWorkspace { .. }
14988 | NodeRequest::ReadWorkspaceFile { .. }
14989 | NodeRequest::WriteWorkspaceFile { .. }
14990 | NodeRequest::CreateWorkspaceFile { .. }
14991 | NodeRequest::CreateWorkspaceDirectory { .. }
14992 | NodeRequest::ReadGitHistory { .. }
14993 | NodeRequest::ReadGitDiff { .. }
14994 | NodeRequest::AcquireController { .. }
14995 | NodeRequest::ReleaseController
14996 | NodeRequest::Spawn { .. }
14997 | NodeRequest::SpawnWithOptions { .. }
14998 | NodeRequest::SpawnSpec { .. }
14999 | NodeRequest::RegisterWorkspace { .. }
15000 | NodeRequest::CreateStandaloneWorkspace { .. }
15001 | NodeRequest::UnregisterWorkspace { .. }
15002 | NodeRequest::CreateWorktree { .. }
15003 | NodeRequest::RemoveWorktree { .. }
15004 | NodeRequest::SpawnManagedWorktree { .. }
15005 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15006 | NodeRequest::CleanupManagedWorktree { .. }
15007 | NodeRequest::IndexProviderSession { .. }
15008 | NodeRequest::IndexNativeSession { .. }
15009 | NodeRequest::CatalogNativeSessions { .. }
15010 | NodeRequest::PageNativeSessions { .. }
15011 | NodeRequest::PreviewNativeSession { .. }
15012 | NodeRequest::PreviewSessionRecord { .. }
15013 | NodeRequest::ForgetContextPack { .. }
15014 | NodeRequest::ResolveDurableContextPack { .. }
15015 | NodeRequest::ReadContextPack { .. }
15016 | NodeRequest::Shutdown => false,
15017 }
15018}
15019
15020fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15021 if request.requires_history_context_pack_capability() {
15022 return true;
15023 }
15024 if let Some(spec) = match request {
15025 NodeRequest::SpawnSpec { spec }
15026 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15027 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15028 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15029 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15030 _ => None,
15031 } {
15032 return shared
15033 .resolve_spawn_spec(spec)
15034 .map_or(true, |resolved| resolved.context_id.is_some());
15035 }
15036 match request {
15037 NodeRequest::Resume { session, .. }
15038 | NodeRequest::Prompt { session, .. }
15039 | NodeRequest::Paste { session, .. }
15040 | NodeRequest::Input { session, .. }
15041 | NodeRequest::TerminalBytes { session, .. }
15042 | NodeRequest::TerminalControl { session, .. }
15043 | NodeRequest::Resize { session, .. }
15044 | NodeRequest::Interrupt { session }
15045 | NodeRequest::Stop { session, .. }
15046 | NodeRequest::Remove { session }
15047 | NodeRequest::ResolveInteraction { session, .. }
15048 | NodeRequest::SetSessionMode { session, .. }
15049 | NodeRequest::SetSessionConfigOption { session, .. }
15050 | NodeRequest::SetSessionModel { session, .. } => shared
15051 .session_bindings
15052 .lock()
15053 .unwrap_or_else(|poisoned| poisoned.into_inner())
15054 .get(&session.session.instance_id)
15055 .filter(|binding| {
15056 binding.workspace_id == session.workspace_id
15057 && binding.generation == session.session.generation
15058 })
15059 .map_or(true, |binding| binding.context.is_some()),
15060 NodeRequest::RenameSessionRecord { record_id, .. }
15061 | NodeRequest::SetSessionTask { record_id, .. }
15062 | NodeRequest::ResumeSessionRecord { record_id, .. }
15063 | NodeRequest::ForgetSessionRecord { record_id } => shared
15064 .record(record_id)
15065 .map_or(true, |record| record.context.is_some()),
15066 NodeRequest::ArmHarnessMcpReservation { .. }
15067 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15068 | NodeRequest::ActivateHarnessMcpReservation { .. }
15069 | NodeRequest::AbortHarnessMcpReservation { .. }
15070 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15071 | NodeRequest::RejectHarnessMcpCall { .. }
15072 | NodeRequest::Snapshot
15073 | NodeRequest::Resync { .. }
15074 | NodeRequest::BeginDeliveryStage { .. }
15075 | NodeRequest::PutDeliveryBlobChunk { .. }
15076 | NodeRequest::CommitDeliveryStage { .. }
15077 | NodeRequest::AbortDeliveryStage { .. }
15078 | NodeRequest::BrowseHostDirectories { .. }
15079 | NodeRequest::InspectWorkspace { .. }
15080 | NodeRequest::ReadWorkspaceFile { .. }
15081 | NodeRequest::WriteWorkspaceFile { .. }
15082 | NodeRequest::CreateWorkspaceFile { .. }
15083 | NodeRequest::CreateWorkspaceDirectory { .. }
15084 | NodeRequest::ReadGitHistory { .. }
15085 | NodeRequest::ReadGitDiff { .. }
15086 | NodeRequest::AcquireController { .. }
15087 | NodeRequest::ReleaseController
15088 | NodeRequest::Spawn { .. }
15089 | NodeRequest::SpawnWithOptions { .. }
15090 | NodeRequest::SpawnSpec { .. }
15091 | NodeRequest::RegisterWorkspace { .. }
15092 | NodeRequest::CreateStandaloneWorkspace { .. }
15093 | NodeRequest::UnregisterWorkspace { .. }
15094 | NodeRequest::CreateWorktree { .. }
15095 | NodeRequest::RemoveWorktree { .. }
15096 | NodeRequest::SpawnManagedWorktree { .. }
15097 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15098 | NodeRequest::CleanupManagedWorktree { .. }
15099 | NodeRequest::IndexProviderSession { .. }
15100 | NodeRequest::IndexNativeSession { .. }
15101 | NodeRequest::CatalogNativeSessions { .. }
15102 | NodeRequest::PageNativeSessions { .. }
15103 | NodeRequest::PreviewNativeSession { .. }
15104 | NodeRequest::PreviewSessionRecord { .. }
15105 | NodeRequest::DiscoverHistory { .. }
15106 | NodeRequest::LoadHistory { .. }
15107 | NodeRequest::ExportContextPackForSessionRecord { .. }
15108 | NodeRequest::ExportContextPack { .. }
15109 | NodeRequest::ForgetContextPack { .. }
15110 | NodeRequest::ResolveDurableContextPack { .. }
15111 | NodeRequest::ReadContextPack { .. }
15112 | NodeRequest::Shutdown => false,
15113 }
15114}
15115
15116fn request_requires_open_provider_ids_with(
15117 request: &NodeRequest,
15118 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15119 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15120) -> bool {
15121 match request {
15122 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15123 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15124 NodeRequest::CatalogNativeSessions { route, .. }
15125 | NodeRequest::PageNativeSessions { route, .. } => {
15126 !provider_id_is_legacy(&route.provider)
15127 }
15128 NodeRequest::PreviewNativeSession { selection, .. }
15129 | NodeRequest::IndexNativeSession { selection, .. } => {
15130 !provider_id_is_legacy(&selection.route.provider)
15131 }
15132 NodeRequest::SpawnSpec { .. }
15133 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15134 NodeRequest::SpawnManagedWorktree { .. }
15135 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15136 NodeRequest::Resume { session, .. }
15137 | NodeRequest::Prompt { session, .. }
15138 | NodeRequest::Paste { session, .. }
15139 | NodeRequest::Input { session, .. }
15140 | NodeRequest::TerminalBytes { session, .. }
15141 | NodeRequest::TerminalControl { session, .. }
15142 | NodeRequest::Resize { session, .. }
15143 | NodeRequest::Interrupt { session }
15144 | NodeRequest::Stop { session, .. }
15145 | NodeRequest::Remove { session }
15146 | NodeRequest::ResolveInteraction { session, .. }
15147 | NodeRequest::SetSessionMode { session, .. }
15148 | NodeRequest::SetSessionConfigOption { session, .. }
15149 | NodeRequest::SetSessionModel { session, .. }
15150 | NodeRequest::DiscoverHistory { session, .. }
15151 | NodeRequest::LoadHistory { session, .. }
15152 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15153 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15154 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15155 NodeRequest::RenameSessionRecord { record_id, .. }
15156 | NodeRequest::SetSessionTask { record_id, .. }
15157 | NodeRequest::ResumeSessionRecord { record_id, .. }
15158 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15159 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15160 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15161 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15162 NodeRequest::ArmHarnessMcpReservation { .. }
15163 | NodeRequest::ActivateHarnessMcpReservation { .. }
15164 | NodeRequest::AbortHarnessMcpReservation { .. }
15165 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15166 | NodeRequest::RejectHarnessMcpCall { .. }
15167 | NodeRequest::Snapshot
15168 | NodeRequest::Resync { .. }
15169 | NodeRequest::BeginDeliveryStage { .. }
15170 | NodeRequest::PutDeliveryBlobChunk { .. }
15171 | NodeRequest::CommitDeliveryStage { .. }
15172 | NodeRequest::AbortDeliveryStage { .. }
15173 | NodeRequest::BrowseHostDirectories { .. }
15174 | NodeRequest::InspectWorkspace { .. }
15175 | NodeRequest::ReadWorkspaceFile { .. }
15176 | NodeRequest::WriteWorkspaceFile { .. }
15177 | NodeRequest::CreateWorkspaceFile { .. }
15178 | NodeRequest::CreateWorkspaceDirectory { .. }
15179 | NodeRequest::ReadGitHistory { .. }
15180 | NodeRequest::ReadGitDiff { .. }
15181 | NodeRequest::AcquireController { .. }
15182 | NodeRequest::ReleaseController
15183 | NodeRequest::RegisterWorkspace { .. }
15184 | NodeRequest::CreateStandaloneWorkspace { .. }
15185 | NodeRequest::UnregisterWorkspace { .. }
15186 | NodeRequest::CreateWorktree { .. }
15187 | NodeRequest::RemoveWorktree { .. }
15188 | NodeRequest::CleanupManagedWorktree { .. }
15189 | NodeRequest::Shutdown => false,
15190 NodeRequest::ForgetContextPack { .. } => true,
15191 NodeRequest::ResolveDurableContextPack { .. }
15197 | NodeRequest::ReadContextPack { .. } => true,
15198 }
15199}
15200
15201fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15202 snapshot.enabled_providers.retain(provider_id_is_legacy);
15203 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15204 snapshot
15205 .provider_runtime_statuses
15206 .as_slice()
15207 .iter()
15208 .filter(|status| provider_id_is_legacy(status.provider()))
15209 .cloned(),
15210 )
15211 .expect("a filtered provider runtime projection remains valid");
15212 snapshot
15213 .session_records
15214 .retain(|record| provider_id_is_legacy(&record.provider));
15215 for workspace in &mut snapshot.workspaces {
15216 workspace
15217 .sessions
15218 .retain(|session| provider_id_is_legacy(&session.agent_id));
15219 }
15220}
15221
15222fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15223 for record in &mut snapshot.session_records {
15224 record.environment_profile = None;
15225 }
15226 if let Some(profiles) = snapshot
15227 .launch_inventory
15228 .as_mut()
15229 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15230 {
15231 for profile in profiles {
15232 profile.environment_profile = None;
15233 }
15234 }
15235}
15236
15237fn project_event_without_child_environment_profile(
15238 mut envelope: NodeEventEnvelope,
15239) -> NodeEventEnvelope {
15240 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15241 record.environment_profile = None;
15242 }
15243 envelope
15244}
15245
15246fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15247 let contains_environment_profile = match reply.result.as_ref() {
15248 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15249 receipt.environment_profile.is_some()
15250 }
15251 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15252 receipt.spawn.environment_profile.is_some()
15253 }
15254 _ => false,
15255 };
15256 if contains_environment_profile {
15257 reply.result = Err(failure(
15258 NodeFailureCode::UnsupportedCapability,
15259 "child environment profiles were not negotiated",
15260 ));
15261 return;
15262 }
15263 let Ok(response) = reply.result.as_mut() else {
15264 return;
15265 };
15266 match response {
15267 NodeResponse::Snapshot { snapshot, .. } => {
15268 clear_snapshot_child_environment_profiles(snapshot);
15269 }
15270 NodeResponse::Resync { snapshot, events, .. } => {
15271 clear_snapshot_child_environment_profiles(snapshot);
15272 for event in events {
15273 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15274 record.environment_profile = None;
15275 }
15276 }
15277 }
15278 NodeResponse::SessionRecordUpdated { record }
15279 | NodeResponse::ProviderSessionIndexed { record }
15280 | NodeResponse::NativeSessionIndexed { record, .. }
15281 | NodeResponse::SessionRecordResumed { record, .. } => {
15282 record.environment_profile = None;
15283 }
15284 NodeResponse::Armed { .. }
15285 | NodeResponse::Spawned { .. }
15286 | NodeResponse::Activated { .. }
15287 | NodeResponse::Aborted { .. }
15288 | NodeResponse::ReplyChunkAccepted { .. }
15289 | NodeResponse::CallRejected { .. }
15290 | NodeResponse::DeliveryStageBegun { .. }
15291 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15292 | NodeResponse::DeliveryCommitted { .. }
15293 | NodeResponse::DeliveryStageAborted { .. }
15294 | NodeResponse::WorkspaceInspected { .. }
15295 | NodeResponse::HostDirectoriesBrowsed { .. }
15296 | NodeResponse::WorkspaceFileRead { .. }
15297 | NodeResponse::WorkspaceFileWritten { .. }
15298 | NodeResponse::WorkspaceFileCreated { .. }
15299 | NodeResponse::WorkspaceDirectoryCreated { .. }
15300 | NodeResponse::GitHistoryRead { .. }
15301 | NodeResponse::GitDiffRead { .. }
15302 | NodeResponse::Controller { .. }
15303 | NodeResponse::SpawnAccepted { .. }
15304 | NodeResponse::SpawnSpecAccepted { .. }
15305 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15306 | NodeResponse::ManagedWorktreeCleanup { .. }
15307 | NodeResponse::NativeSessionsCataloged { .. }
15308 | NodeResponse::NativeSessionsPaged { .. }
15309 | NodeResponse::NativeSessionPreviewed { .. }
15310 | NodeResponse::SessionRecordPreviewed { .. }
15311 | NodeResponse::HistoryDiscovered { .. }
15312 | NodeResponse::HistoryLoaded { .. }
15313 | NodeResponse::ContextPackForSessionRecordExported { .. }
15314 | NodeResponse::ContextPackExported { .. }
15315 | NodeResponse::ContextPackForgotten { .. }
15316 | NodeResponse::DurableContextPackResolved { .. }
15317 | NodeResponse::ContextPackBytesRead { .. }
15318 | NodeResponse::SessionRecordForgotten { .. }
15319 | NodeResponse::WorkspaceRegistered { .. }
15320 | NodeResponse::StandaloneWorkspaceCreated { .. }
15321 | NodeResponse::WorkspaceUnregistered { .. }
15322 | NodeResponse::WorktreeCreated { .. }
15323 | NodeResponse::WorktreeRemoved { .. }
15324 | NodeResponse::Accepted
15325 | NodeResponse::ShuttingDown => {}
15326 }
15327}
15328
15329fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15330 for record in &mut snapshot.session_records {
15331 record.bundle = None;
15332 }
15333}
15334
15335fn project_event_without_session_bundle(
15336 mut envelope: NodeEventEnvelope,
15337) -> NodeEventEnvelope {
15338 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15339 record.bundle = None;
15340 }
15341 envelope
15342}
15343
15344fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15345 let contains_bundle = match reply.result.as_ref() {
15346 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15347 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15348 receipt.spawn.bundle.is_some()
15349 }
15350 _ => false,
15351 };
15352 if contains_bundle {
15353 reply.result = Err(failure(
15354 NodeFailureCode::UnsupportedCapability,
15355 "session bundles were not negotiated",
15356 ));
15357 return;
15358 }
15359 let Ok(response) = reply.result.as_mut() else {
15360 return;
15361 };
15362 match response {
15363 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15364 NodeResponse::Resync { snapshot, events, .. } => {
15365 clear_snapshot_session_bundles(snapshot);
15366 for event in events {
15367 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15368 record.bundle = None;
15369 }
15370 }
15371 }
15372 NodeResponse::SessionRecordUpdated { record }
15373 | NodeResponse::ProviderSessionIndexed { record }
15374 | NodeResponse::NativeSessionIndexed { record, .. }
15375 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15376 NodeResponse::Armed { .. }
15377 | NodeResponse::Spawned { .. }
15378 | NodeResponse::Activated { .. }
15379 | NodeResponse::Aborted { .. }
15380 | NodeResponse::ReplyChunkAccepted { .. }
15381 | NodeResponse::CallRejected { .. }
15382 | NodeResponse::DeliveryStageBegun { .. }
15383 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15384 | NodeResponse::DeliveryCommitted { .. }
15385 | NodeResponse::DeliveryStageAborted { .. }
15386 | NodeResponse::WorkspaceInspected { .. }
15387 | NodeResponse::HostDirectoriesBrowsed { .. }
15388 | NodeResponse::WorkspaceFileRead { .. }
15389 | NodeResponse::WorkspaceFileWritten { .. }
15390 | NodeResponse::WorkspaceFileCreated { .. }
15391 | NodeResponse::WorkspaceDirectoryCreated { .. }
15392 | NodeResponse::GitHistoryRead { .. }
15393 | NodeResponse::GitDiffRead { .. }
15394 | NodeResponse::Controller { .. }
15395 | NodeResponse::SpawnAccepted { .. }
15396 | NodeResponse::SpawnSpecAccepted { .. }
15397 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15398 | NodeResponse::ManagedWorktreeCleanup { .. }
15399 | NodeResponse::NativeSessionsCataloged { .. }
15400 | NodeResponse::NativeSessionsPaged { .. }
15401 | NodeResponse::NativeSessionPreviewed { .. }
15402 | NodeResponse::SessionRecordPreviewed { .. }
15403 | NodeResponse::HistoryDiscovered { .. }
15404 | NodeResponse::HistoryLoaded { .. }
15405 | NodeResponse::ContextPackForSessionRecordExported { .. }
15406 | NodeResponse::ContextPackExported { .. }
15407 | NodeResponse::ContextPackForgotten { .. }
15408 | NodeResponse::DurableContextPackResolved { .. }
15409 | NodeResponse::ContextPackBytesRead { .. }
15410 | NodeResponse::SessionRecordForgotten { .. }
15411 | NodeResponse::WorkspaceRegistered { .. }
15412 | NodeResponse::StandaloneWorkspaceCreated { .. }
15413 | NodeResponse::WorkspaceUnregistered { .. }
15414 | NodeResponse::WorktreeCreated { .. }
15415 | NodeResponse::WorktreeRemoved { .. }
15416 | NodeResponse::Accepted
15417 | NodeResponse::ShuttingDown => {}
15418 }
15419}
15420
15421fn project_snapshot_history_for_wire(
15422 snapshot: &mut NodeSnapshot,
15423 include_history_metadata: bool,
15424) {
15425 for workspace in &mut snapshot.workspaces {
15426 for session in &mut workspace.sessions {
15427 if include_history_metadata {
15428 session.history.loaded = None;
15429 } else {
15430 session.history = Default::default();
15431 }
15432 }
15433 }
15434}
15435
15436fn project_event_history_for_wire(
15437 mut envelope: NodeEventEnvelope,
15438 include_history_metadata: bool,
15439) -> NodeEventEnvelope {
15440 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15441 for session in &mut workspace.sessions {
15442 if include_history_metadata {
15443 session.history.loaded = None;
15444 } else {
15445 session.history = Default::default();
15446 }
15447 }
15448 }
15449 envelope
15450}
15451
15452fn project_response_history_for_wire(
15453 reply: &mut ResponseEnvelope,
15454 include_history_metadata: bool,
15455) {
15456 let Ok(response) = reply.result.as_mut() else {
15457 return;
15458 };
15459 match response {
15460 NodeResponse::Snapshot { snapshot, .. } => {
15461 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15462 }
15463 NodeResponse::Resync {
15464 snapshot, events, ..
15465 } => {
15466 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15467 for event in events {
15468 *event = project_event_history_for_wire(
15469 event.clone(),
15470 include_history_metadata,
15471 );
15472 }
15473 }
15474 NodeResponse::WorkspaceRegistered { workspace }
15475 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15476 | NodeResponse::WorktreeCreated { workspace, .. } => {
15477 for session in &mut workspace.sessions {
15478 if include_history_metadata {
15479 session.history.loaded = None;
15480 } else {
15481 session.history = Default::default();
15482 }
15483 }
15484 }
15485 _ => {}
15486 }
15487}
15488
15489fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15490 for record in &mut snapshot.session_records {
15491 record.context_id = None;
15492 record.context = None;
15493 record.exported_context = None;
15494 }
15495}
15496
15497fn project_event_without_context_pack(
15498 mut envelope: NodeEventEnvelope,
15499) -> NodeEventEnvelope {
15500 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15501 record.context_id = None;
15502 record.context = None;
15503 record.exported_context = None;
15504 }
15505 envelope
15506}
15507
15508fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15509 let contains_context = match reply.result.as_ref() {
15510 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15511 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15512 receipt.spawn.context.is_some()
15513 }
15514 Ok(NodeResponse::HistoryDiscovered { .. })
15515 | Ok(NodeResponse::HistoryLoaded { .. })
15516 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15517 | Ok(NodeResponse::ContextPackExported { .. })
15518 | Ok(NodeResponse::ContextPackForgotten { .. })
15519 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15520 _ => false,
15521 };
15522 if contains_context {
15523 reply.result = Err(failure(
15524 NodeFailureCode::UnsupportedCapability,
15525 "history context packs were not negotiated",
15526 ));
15527 return;
15528 }
15529 let Ok(response) = reply.result.as_mut() else {
15530 return;
15531 };
15532 match response {
15533 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15534 NodeResponse::Resync {
15535 snapshot, events, ..
15536 } => {
15537 clear_snapshot_context_packs(snapshot);
15538 for event in events {
15539 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15540 record.context_id = None;
15541 record.context = None;
15542 record.exported_context = None;
15543 }
15544 }
15545 }
15546 NodeResponse::SessionRecordUpdated { record }
15547 | NodeResponse::ProviderSessionIndexed { record }
15548 | NodeResponse::NativeSessionIndexed { record, .. }
15549 | NodeResponse::SessionRecordResumed { record, .. } => {
15550 record.context_id = None;
15551 record.context = None;
15552 record.exported_context = None;
15553 }
15554 _ => {}
15555 }
15556}
15557
15558fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15559 let contains_open_record = match reply.result.as_ref() {
15560 Ok(NodeResponse::SessionRecordUpdated { record })
15561 | Ok(NodeResponse::ProviderSessionIndexed { record })
15562 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15563 !provider_id_is_legacy(&record.provider)
15564 }
15565 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15566 !provider_id_is_legacy(&selection.route.provider)
15567 || !provider_id_is_legacy(&record.provider)
15568 }
15569 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15570 !provider_id_is_legacy(&receipt.provider)
15571 }
15572 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15573 !provider_id_is_legacy(&receipt.spawn.provider)
15574 }
15575 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15576 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15577 !provider_id_is_legacy(&route.provider)
15578 }
15579 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15580 !provider_id_is_legacy(&selection.route.provider)
15581 }
15582 _ => false,
15583 };
15584 if contains_open_record {
15585 reply.result = Err(failure(
15586 NodeFailureCode::UnsupportedCapability,
15587 "open provider IDs were not negotiated",
15588 ));
15589 return;
15590 }
15591 let Ok(response) = reply.result.as_mut() else {
15592 return;
15593 };
15594 match response {
15595 NodeResponse::Snapshot { snapshot, .. } => {
15596 project_snapshot_legacy_provider_ids(snapshot);
15597 }
15598 NodeResponse::Resync { snapshot, events, .. } => {
15599 project_snapshot_legacy_provider_ids(snapshot);
15600 *events = std::mem::take(events)
15601 .into_iter()
15602 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15603 .collect();
15604 }
15605 NodeResponse::WorkspaceRegistered { workspace }
15606 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15607 | NodeResponse::WorktreeCreated { workspace, .. } => {
15608 workspace
15609 .sessions
15610 .retain(|session| provider_id_is_legacy(&session.agent_id));
15611 }
15612 NodeResponse::Armed { .. }
15613 | NodeResponse::Spawned { .. }
15614 | NodeResponse::Activated { .. }
15615 | NodeResponse::Aborted { .. }
15616 | NodeResponse::ReplyChunkAccepted { .. }
15617 | NodeResponse::CallRejected { .. }
15618 | NodeResponse::DeliveryStageBegun { .. }
15619 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15620 | NodeResponse::DeliveryCommitted { .. }
15621 | NodeResponse::DeliveryStageAborted { .. }
15622 | NodeResponse::WorkspaceInspected { .. }
15623 | NodeResponse::HostDirectoriesBrowsed { .. }
15624 | NodeResponse::WorkspaceFileRead { .. }
15625 | NodeResponse::WorkspaceFileWritten { .. }
15626 | NodeResponse::WorkspaceFileCreated { .. }
15627 | NodeResponse::WorkspaceDirectoryCreated { .. }
15628 | NodeResponse::GitHistoryRead { .. }
15629 | NodeResponse::GitDiffRead { .. }
15630 | NodeResponse::Controller { .. }
15631 | NodeResponse::SpawnAccepted { .. }
15632 | NodeResponse::SpawnSpecAccepted { .. }
15633 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15634 | NodeResponse::ManagedWorktreeCleanup { .. }
15635 | NodeResponse::NativeSessionsCataloged { .. }
15636 | NodeResponse::NativeSessionsPaged { .. }
15637 | NodeResponse::NativeSessionPreviewed { .. }
15638 | NodeResponse::SessionRecordPreviewed { .. }
15639 | NodeResponse::HistoryDiscovered { .. }
15640 | NodeResponse::HistoryLoaded { .. }
15641 | NodeResponse::ContextPackForSessionRecordExported { .. }
15642 | NodeResponse::ContextPackExported { .. }
15643 | NodeResponse::ContextPackForgotten { .. }
15644 | NodeResponse::DurableContextPackResolved { .. }
15645 | NodeResponse::ContextPackBytesRead { .. }
15646 | NodeResponse::SessionRecordUpdated { .. }
15647 | NodeResponse::ProviderSessionIndexed { .. }
15648 | NodeResponse::NativeSessionIndexed { .. }
15649 | NodeResponse::SessionRecordResumed { .. }
15650 | NodeResponse::SessionRecordForgotten { .. }
15651 | NodeResponse::WorkspaceUnregistered { .. }
15652 | NodeResponse::WorktreeRemoved { .. }
15653 | NodeResponse::Accepted
15654 | NodeResponse::ShuttingDown => {}
15655 }
15656}
15657
15658fn project_event_legacy_provider_ids(
15659 shared: &NodeShared,
15660 mut envelope: NodeEventEnvelope,
15661) -> Option<NodeEventEnvelope> {
15662 let include = match &mut envelope.event {
15663 NodeEvent::HarnessMcpReadCall { .. } => true,
15664 NodeEvent::Control { address, .. }
15665 | NodeEvent::AgentStream { address, .. }
15666 | NodeEvent::TerminalFrame { address, .. } => shared
15667 .validate_address(address)
15668 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15669 NodeEvent::WorkspaceAdded { workspace } => {
15670 workspace
15671 .sessions
15672 .retain(|session| provider_id_is_legacy(&session.agent_id));
15673 true
15674 }
15675 NodeEvent::SessionRecordUpserted { record } => {
15676 provider_id_is_legacy(&record.provider)
15677 }
15678 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15679 NodeEvent::SessionRecordRemoved { .. } => shared
15680 .history
15681 .lock()
15682 .unwrap_or_else(|poisoned| poisoned.into_inner())
15683 .removed_record_providers
15684 .get(&envelope.sequence)
15685 .is_some_and(provider_id_is_legacy),
15686 NodeEvent::ControllerChanged { .. }
15687 | NodeEvent::ManagedWorktreeUpserted { .. }
15688 | NodeEvent::ManagedWorktreeRemoved { .. }
15689 | NodeEvent::WorkspaceRemoved { .. }
15690 | NodeEvent::ResyncRequired { .. } => true,
15691 };
15692 include.then_some(envelope)
15693}
15694
15695fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15696 let Ok(response) = reply.result.as_mut() else {
15697 return;
15698 };
15699 match response {
15700 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15701 snapshot.provider_runtime_statuses.clear();
15702 }
15703 NodeResponse::Armed { .. }
15704 | NodeResponse::Spawned { .. }
15705 | NodeResponse::Activated { .. }
15706 | NodeResponse::Aborted { .. }
15707 | NodeResponse::ReplyChunkAccepted { .. }
15708 | NodeResponse::CallRejected { .. }
15709 | NodeResponse::DeliveryStageBegun { .. }
15710 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15711 | NodeResponse::DeliveryCommitted { .. }
15712 | NodeResponse::DeliveryStageAborted { .. }
15713 | NodeResponse::WorkspaceInspected { .. }
15714 | NodeResponse::HostDirectoriesBrowsed { .. }
15715 | NodeResponse::WorkspaceFileRead { .. }
15716 | NodeResponse::WorkspaceFileWritten { .. }
15717 | NodeResponse::WorkspaceFileCreated { .. }
15718 | NodeResponse::WorkspaceDirectoryCreated { .. }
15719 | NodeResponse::GitHistoryRead { .. }
15720 | NodeResponse::GitDiffRead { .. }
15721 | NodeResponse::Controller { .. }
15722 | NodeResponse::SpawnAccepted { .. }
15723 | NodeResponse::SpawnSpecAccepted { .. }
15724 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15725 | NodeResponse::ManagedWorktreeCleanup { .. }
15726 | NodeResponse::NativeSessionsCataloged { .. }
15727 | NodeResponse::NativeSessionsPaged { .. }
15728 | NodeResponse::NativeSessionPreviewed { .. }
15729 | NodeResponse::SessionRecordPreviewed { .. }
15730 | NodeResponse::HistoryDiscovered { .. }
15731 | NodeResponse::HistoryLoaded { .. }
15732 | NodeResponse::ContextPackForSessionRecordExported { .. }
15733 | NodeResponse::ContextPackExported { .. }
15734 | NodeResponse::ContextPackForgotten { .. }
15735 | NodeResponse::DurableContextPackResolved { .. }
15736 | NodeResponse::ContextPackBytesRead { .. }
15737 | NodeResponse::SessionRecordUpdated { .. }
15738 | NodeResponse::ProviderSessionIndexed { .. }
15739 | NodeResponse::NativeSessionIndexed { .. }
15740 | NodeResponse::SessionRecordResumed { .. }
15741 | NodeResponse::SessionRecordForgotten { .. }
15742 | NodeResponse::WorkspaceRegistered { .. }
15743 | NodeResponse::StandaloneWorkspaceCreated { .. }
15744 | NodeResponse::WorkspaceUnregistered { .. }
15745 | NodeResponse::WorktreeCreated { .. }
15746 | NodeResponse::WorktreeRemoved { .. }
15747 | NodeResponse::Accepted
15748 | NodeResponse::ShuttingDown => {}
15749 }
15750}
15751
15752struct ControllerReleaseGuard {
15753 shared: Arc<NodeShared>,
15754 connection_id: u64,
15755}
15756
15757impl Drop for ControllerReleaseGuard {
15758 fn drop(&mut self) {
15759 self.shared.release_controller(self.connection_id);
15760 }
15761}
15762
15763struct AbortTaskOnDrop(AbortHandle);
15764
15765impl Drop for AbortTaskOnDrop {
15766 fn drop(&mut self) {
15767 self.0.abort();
15768 }
15769}
15770
15771fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15776 std::env::var(name)
15777 .ok()
15778 .and_then(|value| value.parse::<u64>().ok())
15779 .filter(|value| *value > 0)
15780 .unwrap_or(default)
15781 .min(max)
15782}
15783
15784fn workspace_inspection_time_budget_ms() -> u64 {
15785 env_config_bounded(
15786 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15787 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15788 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15789 )
15790}
15791
15792fn workspace_inspection_entry_cap() -> usize {
15793 env_config_bounded(
15794 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15795 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15796 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15797 ) as usize
15798}
15799
15800struct WorkspaceWalkBudget {
15806 deadline: Instant,
15807 entry_cap: usize,
15808 entries_visited: u64,
15809 time_budget_exceeded: bool,
15810 entry_cap_exceeded: bool,
15811}
15812
15813impl WorkspaceWalkBudget {
15814 fn new(deadline: Instant, entry_cap: usize) -> Self {
15815 Self {
15816 deadline,
15817 entry_cap,
15818 entries_visited: 0,
15819 time_budget_exceeded: false,
15820 entry_cap_exceeded: false,
15821 }
15822 }
15823
15824 fn is_exhausted(&self) -> bool {
15825 self.time_budget_exceeded || self.entry_cap_exceeded
15826 }
15827
15828 fn record_visit(&mut self) -> bool {
15831 self.entries_visited += 1;
15832 if self.entries_visited as usize >= self.entry_cap {
15833 self.entry_cap_exceeded = true;
15834 }
15835 if Instant::now() >= self.deadline {
15836 self.time_budget_exceeded = true;
15837 }
15838 self.is_exhausted()
15839 }
15840}
15841
15842fn collect_workspace_entries(
15843 root: &Path,
15844 deadline: Instant,
15845 entry_cap: usize,
15846) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15847 let mut entries = Vec::new();
15848 let mut truncated = false;
15849 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15850 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15851 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15855 (entries, truncated, budget)
15856}
15857
15858fn workspace_inspection_truncation(
15862 walk_budget: &WorkspaceWalkBudget,
15863 git_time_budget_exceeded: bool,
15864 elapsed: Duration,
15865) -> Option<WorkspaceInspectionTruncationV1> {
15866 if !walk_budget.time_budget_exceeded
15867 && !walk_budget.entry_cap_exceeded
15868 && !git_time_budget_exceeded
15869 {
15870 return None;
15871 }
15872 Some(WorkspaceInspectionTruncationV1 {
15873 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15874 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15875 git_time_budget_exceeded,
15876 entries_visited: walk_budget.entries_visited,
15877 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15878 })
15879}
15880
15881fn walk_workspace_directory(
15882 directory: &Path,
15883 relative_directory: Option<&RepositoryPath>,
15884 depth: usize,
15885 entries: &mut Vec<WorkspaceEntry>,
15886 truncated: &mut bool,
15887 budget: &mut WorkspaceWalkBudget,
15888) {
15889 if budget.is_exhausted() {
15890 *truncated = true;
15891 return;
15892 }
15893 let read_dir = match std::fs::read_dir(directory) {
15894 Ok(read_dir) => read_dir,
15895 Err(_) => {
15896 *truncated = true;
15897 return;
15898 }
15899 };
15900 let mut children = Vec::new();
15901 for child in read_dir {
15902 match child {
15903 Ok(child) => children.push(child),
15904 Err(_) => *truncated = true,
15905 }
15906 }
15907 children.sort_by_key(|child| child.file_name());
15908 for child in children {
15909 if budget.record_visit() {
15910 *truncated = true;
15911 return;
15912 }
15913 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15914 *truncated = true;
15915 return;
15916 }
15917 let file_type = match child.file_type() {
15918 Ok(file_type) => file_type,
15919 Err(_) => {
15920 *truncated = true;
15921 continue;
15922 }
15923 };
15924 if file_type.is_symlink() {
15925 continue;
15926 }
15927 let name = match child.file_name().into_string() {
15928 Ok(name) => name,
15929 Err(_) => {
15930 *truncated = true;
15931 continue;
15932 }
15933 };
15934 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15935 continue;
15936 }
15937 let relative_path = match relative_directory {
15938 None => windows_repository_path(name),
15939 Some(parent) => parent
15940 .as_utf8()
15941 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15942 };
15943 let Some(relative_path) = relative_path else {
15944 *truncated = true;
15945 continue;
15946 };
15947 let kind = if file_type.is_dir() {
15948 WorkspaceEntryKind::Directory
15949 } else if file_type.is_file() {
15950 WorkspaceEntryKind::File
15951 } else {
15952 continue;
15953 };
15954 entries.push(WorkspaceEntry {
15955 relative_path: relative_path.clone(),
15956 kind,
15957 });
15958 if file_type.is_dir() {
15959 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15960 walk_workspace_directory(
15961 &child.path(),
15962 Some(&relative_path),
15963 depth + 1,
15964 entries,
15965 truncated,
15966 budget,
15967 );
15968 } else {
15969 *truncated = true;
15970 }
15971 }
15972 }
15973}
15974
15975fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15976 if value.contains('\\') {
15977 return None;
15978 }
15979 RepositoryPath::utf8(value).ok()
15980}
15981
15982fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15983 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15984}
15985
15986fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15995 if name.eq_ignore_ascii_case(".git")
15996 || name.eq_ignore_ascii_case("target")
15997 || name.eq_ignore_ascii_case("node_modules")
15998 {
15999 return true;
16000 }
16001 path.join("CACHEDIR.TAG").is_file()
16002}
16003
16004fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16005 matches!(
16006 status,
16007 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16008 )
16009}
16010
16011#[derive(Clone, Debug, Eq, PartialEq)]
16012enum ContextPackRepositoryHead {
16013 NotRepository,
16014 Unborn,
16015 Commit(String),
16016}
16017
16018#[derive(Clone, Debug, Eq, PartialEq)]
16019struct ContextPackRepositoryObservation {
16020 head: ContextPackRepositoryHead,
16021 git: GitSnapshot,
16022 files: Vec<ContextPackRepositoryFileSource>,
16023}
16024
16025fn stable_context_pack_repository(
16026 first: ContextPackRepositoryObservation,
16027 second: ContextPackRepositoryObservation,
16028) -> Result<ContextPackRepository, NodeFailure> {
16029 if first != second {
16030 return Err(failure(
16031 NodeFailureCode::ContextPackMaterializationFailed,
16032 "context repository changed during bounded capture",
16033 ));
16034 }
16035 Ok(ContextPackRepository::from_sources(
16036 &second.git,
16037 second.files,
16038 ))
16039}
16040
16041fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16048 if Instant::now() < deadline {
16049 return false;
16050 }
16051 append_git_diagnostic(
16052 snapshot,
16053 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16054 );
16055 true
16056}
16057
16058async fn run_git_bounded_with_deadline(
16067 root: &str,
16068 arguments: &[&str],
16069 output_limit: usize,
16070 deadline: Instant,
16071) -> io::Result<GitCommandOutput> {
16072 let remaining = deadline.checked_duration_since(Instant::now());
16073 let timeout_ms = remaining
16074 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16075 .unwrap_or(0)
16076 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16077 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16078}
16079
16080async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16087 let mut snapshot = GitSnapshot {
16088 is_repository: false,
16089 branch: None,
16090 status: Vec::new(),
16091 recent_commits: Vec::new(),
16092 worktrees: Vec::new(),
16093 managed_worktree: None,
16094 truncated: false,
16095 diagnostic: None,
16096 };
16097 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16098 return (snapshot, true);
16099 }
16100 let repository = match run_git_bounded_with_deadline(
16101 root,
16102 &["rev-parse", "--is-inside-work-tree"],
16103 4 * 1_024,
16104 deadline,
16105 )
16106 .await
16107 {
16108 Ok(output) => output,
16109 Err(error) => {
16110 append_git_diagnostic(
16111 &mut snapshot,
16112 &format!("git inspection unavailable: {error}"),
16113 );
16114 return (snapshot, false);
16115 }
16116 };
16117 snapshot.truncated |= repository.truncated;
16118 if repository.timed_out {
16119 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16120 return (snapshot, false);
16121 }
16122 if !repository.success
16123 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16124 {
16125 let stderr = String::from_utf8_lossy(&repository.stderr);
16126 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16127 append_git_diagnostic(&mut snapshot, stderr.trim());
16128 }
16129 return (snapshot, false);
16130 }
16131 snapshot.is_repository = true;
16132
16133 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16134 return (snapshot, true);
16135 }
16136 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16137 Ok(output) => {
16138 snapshot.truncated |= output.truncated;
16139 if output.timed_out {
16140 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16141 } else if output.success {
16142 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16143 if !branch.is_empty() {
16144 snapshot.branch = Some(truncate_git_field(
16145 &branch,
16146 MAX_REPOSITORY_PATH_BYTES,
16147 &mut snapshot.truncated,
16148 ));
16149 }
16150 }
16151 }
16152 Err(error) => append_git_diagnostic(
16153 &mut snapshot,
16154 &format!("git branch query failed: {error}"),
16155 ),
16156 }
16157 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16158 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16159 snapshot.truncated |= output.truncated;
16160 if output.success && !output.timed_out {
16161 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16162 if !head.is_empty() {
16163 snapshot.branch = Some(format!("detached:{head}"));
16164 }
16165 }
16166 }
16167 }
16168
16169 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16170 return (snapshot, true);
16171 }
16172 match run_git_bounded_with_deadline(
16173 root,
16174 &[
16175 "status",
16176 "--porcelain=v1",
16177 "-z",
16178 "--renames",
16179 "--untracked-files=normal",
16180 "--",
16181 ".",
16182 ],
16183 GIT_OUTPUT_MAX_BYTES,
16184 deadline,
16185 )
16186 .await
16187 {
16188 Ok(output) => {
16189 snapshot.truncated |= output.truncated;
16190 if output.timed_out {
16191 append_git_diagnostic(&mut snapshot, "git status timed out");
16192 } else if output.success {
16193 parse_git_status(&output.stdout, &mut snapshot);
16194 } else {
16195 append_git_diagnostic(
16196 &mut snapshot,
16197 String::from_utf8_lossy(&output.stderr).trim(),
16198 );
16199 }
16200 }
16201 Err(error) => append_git_diagnostic(
16202 &mut snapshot,
16203 &format!("git status failed: {error}"),
16204 ),
16205 }
16206
16207 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16208 return (snapshot, true);
16209 }
16210 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16211 match run_git_bounded_with_deadline(
16212 root,
16213 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16214 16 * 1_024,
16215 deadline,
16216 )
16217 .await
16218 {
16219 Ok(output) => {
16220 snapshot.truncated |= output.truncated;
16221 if output.timed_out {
16222 append_git_diagnostic(&mut snapshot, "git log timed out");
16223 } else if output.success {
16224 parse_git_commits(&output.stdout, &mut snapshot);
16225 } else {
16226 let stderr = String::from_utf8_lossy(&output.stderr);
16227 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16228 append_git_diagnostic(&mut snapshot, stderr.trim());
16229 }
16230 }
16231 }
16232 Err(error) => append_git_diagnostic(
16233 &mut snapshot,
16234 &format!("git log failed: {error}"),
16235 ),
16236 }
16237
16238 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16239 return (snapshot, true);
16240 }
16241 match list_git_worktrees_with_deadline(root, deadline).await {
16242 Ok(worktrees) => {
16243 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16244 }
16245 Err(error) => append_git_diagnostic(
16246 &mut snapshot,
16247 &format!("git worktree list failed: {}", error.message),
16248 ),
16249 }
16250 (snapshot, false)
16251}
16252
16253fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16254 let mut remaining = output;
16255 while !remaining.is_empty() {
16256 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16257 snapshot.truncated = true;
16258 break;
16259 }
16260 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16261 snapshot.truncated = true;
16262 break;
16263 };
16264 let record = &remaining[..record_end];
16265 remaining = &remaining[record_end + 1..];
16266 let has_previous_path = record
16267 .get(..2)
16268 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16269 let previous_path_bytes = if has_previous_path {
16270 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16271 snapshot.truncated = true;
16272 break;
16273 };
16274 let previous = &remaining[..previous_end];
16275 remaining = &remaining[previous_end + 1..];
16276 Some(previous)
16277 } else {
16278 None
16279 };
16280 if record.len() < 4
16281 || record[2] != b' '
16282 || !is_git_status_code(record[0])
16283 || !is_git_status_code(record[1])
16284 {
16285 snapshot.truncated = true;
16286 continue;
16287 }
16288 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16289 snapshot.truncated = true;
16290 continue;
16291 };
16292 let previous_path = match previous_path_bytes {
16293 Some(previous) => {
16294 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16295 snapshot.truncated = true;
16296 continue;
16297 };
16298 Some(previous)
16299 }
16300 None => None,
16301 };
16302 snapshot.status.push(GitStatusEntry {
16303 index_status: (record[0] as char).to_string(),
16304 worktree_status: (record[1] as char).to_string(),
16305 path,
16306 previous_path,
16307 });
16308 }
16309}
16310
16311fn is_git_status_code(byte: u8) -> bool {
16312 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16313}
16314
16315fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16316 for line in String::from_utf8_lossy(output).lines() {
16317 let Some((id, summary)) = line.split_once('\u{1f}') else {
16318 continue;
16319 };
16320 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16321 snapshot.truncated = true;
16322 break;
16323 }
16324 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16325 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16326 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16327 }
16328}
16329
16330fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16331 if value.len() <= max_bytes {
16332 return value.to_owned();
16333 }
16334 *truncated = true;
16335 let mut boundary = max_bytes;
16336 while boundary > 0 && !value.is_char_boundary(boundary) {
16337 boundary -= 1;
16338 }
16339 value[..boundary].to_owned()
16340}
16341
16342fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16343 if message.is_empty() {
16344 return;
16345 }
16346 let normalized = message
16347 .chars()
16348 .map(|character| if character.is_control() { ' ' } else { character })
16349 .collect::<String>();
16350 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16351 if !diagnostic.is_empty() {
16352 diagnostic.push_str("; ");
16353 }
16354 diagnostic.push_str(normalized.trim());
16355 snapshot.diagnostic = Some(truncate_git_field(
16356 &diagnostic,
16357 GIT_DIAGNOSTIC_MAX_BYTES,
16358 &mut snapshot.truncated,
16359 ));
16360}
16361
16362async fn run_git_bounded(
16363 root: &str,
16364 arguments: &[&str],
16365 output_limit: usize,
16366) -> io::Result<GitCommandOutput> {
16367 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16368}
16369
16370async fn read_git_history_bounded(
16371 root: &str,
16372 path: Option<&RepositoryPath>,
16373 before: Option<&GitObjectId>,
16374 limit: u16,
16375) -> Result<GitHistoryPage, NodeFailure> {
16376 let path = path
16377 .map(|path| {
16378 path.as_utf8().ok_or_else(|| {
16379 failure(
16380 NodeFailureCode::InvalidRepositoryPath,
16381 "git history path must use UTF-8",
16382 )
16383 })
16384 })
16385 .transpose()?
16386 .unwrap_or(".");
16387 let count = (usize::from(limit) + 1).to_string();
16388 let start = before
16389 .map(|revision| format!("{}^", revision.as_str()))
16390 .unwrap_or_else(|| "HEAD".to_owned());
16391 let arguments = [
16392 "--no-pager",
16393 "log",
16394 "--no-decorate",
16395 "--date=iso-strict",
16396 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16397 "-n",
16398 count.as_str(),
16399 start.as_str(),
16400 "--",
16401 path,
16402 ];
16403 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16404 .await
16405 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16406 if output.timed_out {
16407 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16408 }
16409 if !output.success {
16410 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16411 }
16412 let mut commits = parse_git_history(&output.stdout);
16413 let has_more = commits.len() > usize::from(limit) || output.truncated;
16414 commits.truncate(usize::from(limit));
16415 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16416 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16417}
16418
16419fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16420 String::from_utf8_lossy(output)
16421 .split('\u{1e}')
16422 .filter_map(|record| {
16423 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16424 if fields.len() != 9 {
16425 return None;
16426 }
16427 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16428 let parents = if fields[1].is_empty() {
16429 Vec::new()
16430 } else {
16431 fields[1]
16432 .split(' ')
16433 .map(|value| GitObjectId::new(value.to_owned()))
16434 .collect::<Result<Vec<_>, _>>()
16435 .ok()?
16436 };
16437 Some(GitCommitDetails {
16438 id,
16439 parents,
16440 subject: bounded_git_text(fields[2], 512),
16441 author_name: bounded_git_text(fields[3], 256),
16442 author_email: bounded_git_text(fields[4], 320),
16443 authored_at: bounded_git_text(fields[5], 64),
16444 committer_name: bounded_git_text(fields[6], 256),
16445 committer_email: bounded_git_text(fields[7], 320),
16446 committed_at: bounded_git_text(fields[8], 64),
16447 signature_status: GitSignatureStatus::NoSignature,
16448 signer: None,
16449 })
16450 })
16451 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16452 .collect()
16453}
16454
16455fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16456 let normalized = value
16457 .chars()
16458 .map(|character| if character.is_control() { ' ' } else { character })
16459 .collect::<String>();
16460 if normalized.len() <= max_bytes {
16461 return normalized;
16462 }
16463 let mut boundary = max_bytes;
16464 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16465 boundary -= 1;
16466 }
16467 normalized[..boundary].to_owned()
16468}
16469
16470async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16471 let path = request.path.as_ref().map(|path| {
16472 path.as_utf8()
16473 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16474 }).transpose()?;
16475 let mut arguments = match &request.mode {
16476 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16477 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16478 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16479 };
16480 if let GitDiffMode::Commit { revision } = &request.mode {
16481 arguments.push(revision.as_str());
16482 }
16483 arguments.push("--");
16484 if let Some(path) = path {
16485 arguments.push(path);
16486 }
16487 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16488 .await
16489 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16490 if output.timed_out {
16491 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16492 }
16493 if !output.success {
16494 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16495 }
16496 Ok(GitDiff {
16497 mode: request.mode,
16498 path: request.path,
16499 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16500 truncated: output.truncated,
16501 })
16502}
16503
16504async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16505 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16506 ResponseEnvelope { request_id: envelope.request_id, result }
16507}
16508
16509async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16510 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16511 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16512 }
16513 let read_only = matches!(
16514 &request,
16515 NodeRequest::Snapshot
16516 | NodeRequest::Resync { .. }
16517 | NodeRequest::BrowseHostDirectories { .. }
16518 | NodeRequest::InspectWorkspace { .. }
16519 | NodeRequest::ReadWorkspaceFile { .. }
16520 | NodeRequest::ReadGitHistory { .. }
16521 | NodeRequest::ReadGitDiff { .. }
16522 | NodeRequest::CatalogNativeSessions { .. }
16523 | NodeRequest::PageNativeSessions { .. }
16524 | NodeRequest::PreviewNativeSession { .. }
16525 | NodeRequest::PreviewSessionRecord { .. }
16526 );
16527 let _mutation_guard = if read_only {
16528 None
16529 } else {
16530 let guard = shared.mutation_gate.lock().await;
16531 if shared.shutdown.load(Ordering::Acquire) {
16532 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16533 }
16534 Some(guard)
16535 };
16536 match request {
16537 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16538 event_sequence: shared.current_sequence(),
16539 controller: shared.controller_state(),
16540 snapshot: shared.snapshot(),
16541 }),
16542 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16543 NodeRequest::ArmHarnessMcpReservation {
16544 reservation_id,
16545 activation_digest,
16546 spawn_spec,
16547 launch,
16548 expires_at_unix_ms,
16549 } => {
16550 shared.require_controller(connection_id, role)?;
16551 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16552 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16553 })?;
16554 let expires_at_unix_ms = registry
16555 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16556 .await
16557 .map_err(harness_mcp_failure)?;
16558 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16559 }
16560 NodeRequest::SpawnSpecWithHarnessMcp {
16561 reservation_id,
16562 activation_digest,
16563 spec,
16564 deadline_unix_ms,
16565 } => {
16566 shared.require_controller(connection_id, role)?;
16567 let receipt = shared
16568 .spawn_from_spec_with_harness_mcp(
16569 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16570 )
16571 .await?;
16572 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16573 }
16574 NodeRequest::ActivateHarnessMcpReservation {
16575 reservation_id,
16576 activation_digest,
16577 record_id,
16578 session,
16579 } => {
16580 shared.require_controller(connection_id, role)?;
16581 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16582 current.instance_id == session.session.instance_id
16583 && current.generation == session.session.generation
16584 }).and_then(|current| current.process_id).ok_or_else(|| {
16585 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16586 })?;
16587 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16588 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16589 })?.activate(
16590 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16591 ).map_err(harness_mcp_failure)?;
16592 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16593 }
16594 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16595 shared.require_controller(connection_id, role)?;
16596 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16597 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16598 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16599 if let Some(instance_id) = instance_id {
16600 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16601 }
16602 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16603 }
16604 NodeRequest::PutHarnessMcpReplyChunk {
16605 reservation_id, activation_digest, record_id, session, call_id,
16606 offset, final_chunk, chunk_hex,
16607 } => {
16608 shared.require_controller(connection_id, role)?;
16609 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16610 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16611 })?.put_reply_chunk(
16612 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16613 offset, final_chunk, &chunk_hex,
16614 ).map_err(harness_mcp_failure)?;
16615 Ok(NodeResponse::ReplyChunkAccepted {
16616 reservation_id, activation_digest, record_id, session, call_id,
16617 next_offset, completed,
16618 })
16619 }
16620 NodeRequest::RejectHarnessMcpCall {
16621 reservation_id, activation_digest, record_id, session, call_id, reason,
16622 } => {
16623 shared.require_controller(connection_id, role)?;
16624 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16625 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16626 })?.reject_call(
16627 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16628 ).map_err(harness_mcp_failure)?;
16629 Ok(NodeResponse::CallRejected {
16630 reservation_id, activation_digest, record_id, session, call_id,
16631 })
16632 }
16633 NodeRequest::BeginDeliveryStage { manifest } => {
16634 shared.require_controller(connection_id, role)?;
16635 shared.begin_delivery_stage(manifest)
16636 }
16637 NodeRequest::PutDeliveryBlobChunk {
16638 stage_id,
16639 blob_digest,
16640 offset,
16641 chunk_hex,
16642 } => {
16643 shared.require_controller(connection_id, role)?;
16644 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16645 }
16646 NodeRequest::CommitDeliveryStage { stage_id } => {
16647 shared.require_controller(connection_id, role)?;
16648 let receipt = shared.commit_delivery_stage(stage_id)?;
16649 Ok(NodeResponse::DeliveryCommitted { receipt })
16650 }
16651 NodeRequest::AbortDeliveryStage { stage_id } => {
16652 shared.require_controller(connection_id, role)?;
16653 shared.abort_delivery_stage(&stage_id)?;
16654 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16655 }
16656 NodeRequest::BrowseHostDirectories { directory, after } => {
16657 let listing = shared.browse_host_directories(directory, after).await?;
16658 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16659 }
16660 NodeRequest::InspectWorkspace { workspace_id } => {
16661 let inspection = shared.inspect_workspace(workspace_id).await?;
16662 Ok(NodeResponse::WorkspaceInspected { inspection })
16663 }
16664 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16665 let file = shared.read_workspace_file(workspace_id, path).await?;
16666 Ok(NodeResponse::WorkspaceFileRead { file })
16667 }
16668 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16669 shared.require_controller(connection_id, role)?;
16670 let file = shared
16671 .write_workspace_file(workspace_id, path, expected_revision, text)
16672 .await?;
16673 Ok(NodeResponse::WorkspaceFileWritten { file })
16674 }
16675 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16676 shared.require_controller(connection_id, role)?;
16677 let file = shared.create_workspace_file(workspace_id, path).await?;
16678 Ok(NodeResponse::WorkspaceFileCreated { file })
16679 }
16680 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16681 shared.require_controller(connection_id, role)?;
16682 let entry = shared
16683 .create_workspace_directory(workspace_id.clone(), path)
16684 .await?;
16685 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16686 }
16687 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16688 if role != ClientRole::Operator {
16689 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16690 }
16691 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16692 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16693 }
16694 let page = shared
16695 .read_git_history(workspace_id.clone(), path, before, limit)
16696 .await?;
16697 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16698 }
16699 NodeRequest::ReadGitDiff { workspace_id, request } => {
16700 if role != ClientRole::Operator {
16701 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16702 }
16703 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16704 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16705 }
16706 NodeRequest::CatalogNativeSessions { route, limit } => {
16707 if role != ClientRole::Operator {
16708 return Err(failure(
16709 NodeFailureCode::ObserverReadOnly,
16710 "native session catalog requires an operator connection",
16711 ));
16712 }
16713 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16714 return Err(failure(
16715 NodeFailureCode::InvalidRequest,
16716 "native session catalog limit is invalid",
16717 ));
16718 }
16719 let (entries, summary) = shared
16720 .catalog_native_sessions(route.clone(), limit)
16721 .await?;
16722 Ok(NodeResponse::NativeSessionsCataloged {
16723 route,
16724 entries,
16725 summary: Some(summary),
16726 })
16727 }
16728 NodeRequest::PageNativeSessions {
16729 route,
16730 window,
16731 catalog_revision,
16732 recent_cutoff_unix_ms,
16733 after_selection_id,
16734 limit,
16735 } => {
16736 if role != ClientRole::Operator {
16737 return Err(failure(
16738 NodeFailureCode::ObserverReadOnly,
16739 "native session catalog paging requires an operator connection",
16740 ));
16741 }
16742 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16743 || after_selection_id
16744 .as_deref()
16745 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16746 {
16747 return Err(failure(
16748 NodeFailureCode::InvalidRequest,
16749 "native session catalog page request is invalid",
16750 ));
16751 }
16752 let page = shared
16753 .page_native_sessions(
16754 route.clone(),
16755 window,
16756 catalog_revision,
16757 recent_cutoff_unix_ms,
16758 after_selection_id,
16759 limit,
16760 )
16761 .await?;
16762 Ok(NodeResponse::NativeSessionsPaged {
16763 route,
16764 page,
16765 })
16766 }
16767 NodeRequest::PreviewNativeSession {
16768 selection,
16769 message_limit,
16770 } => {
16771 if role != ClientRole::Operator {
16772 return Err(failure(
16773 NodeFailureCode::ObserverReadOnly,
16774 "native session preview requires an operator connection",
16775 ));
16776 }
16777 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16778 .contains(&message_limit)
16779 || selection.validate().is_err()
16780 {
16781 return Err(failure(
16782 NodeFailureCode::InvalidRequest,
16783 "native session preview request is invalid",
16784 ));
16785 }
16786 let preview = shared
16787 .preview_native_session(selection.clone(), message_limit)
16788 .await?;
16789 Ok(NodeResponse::NativeSessionPreviewed {
16790 selection,
16791 preview,
16792 })
16793 }
16794 NodeRequest::IndexNativeSession {
16795 selection,
16796 display_name,
16797 } => {
16798 shared.require_controller(connection_id, role)?;
16799 let record = shared
16800 .index_native_session(selection.clone(), display_name)
16801 .await?;
16802 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16803 }
16804 NodeRequest::PreviewSessionRecord {
16805 record_id,
16806 message_limit,
16807 } => {
16808 if role != ClientRole::Operator {
16809 return Err(failure(
16810 NodeFailureCode::ObserverReadOnly,
16811 "native session preview requires an operator connection",
16812 ));
16813 }
16814 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16815 .contains(&message_limit)
16816 {
16817 return Err(failure(
16818 NodeFailureCode::InvalidRequest,
16819 "native session preview request is invalid",
16820 ));
16821 }
16822 let record = shared.record(&record_id)?;
16823 let working_directory = shared.workspace_root(&record.workspace_id)?;
16824 if !platform::roots_equal(
16825 &working_directory,
16826 windows_path_text(&record.canonical_root),
16827 ) {
16828 return Err(failure(
16829 NodeFailureCode::SessionRecordConflict,
16830 "managed session workspace root changed; refusing to preview another directory",
16831 ));
16832 }
16833 let identity = record.provider_session.clone().ok_or_else(|| {
16834 failure(
16835 NodeFailureCode::BackendOperationFailed,
16836 "native session preview is unavailable for this session record",
16837 )
16838 })?;
16839 if identity.key != ProviderSessionKey::SessionId {
16840 return Err(failure(
16841 NodeFailureCode::BackendOperationFailed,
16842 "native session preview is unavailable for this session identity",
16843 ));
16844 }
16845 let preview = shared
16846 .preview_native_session_by_session_id(
16847 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16848 )
16849 .await?;
16850 if preview.session_id != identity.id {
16851 return Err(failure(
16852 NodeFailureCode::SessionRecordConflict,
16853 "native session preview identity changed while loading",
16854 ));
16855 }
16856 shared.revalidate_session_record_preview(&record, &identity)?;
16857 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16858 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16859 record_id: record_id.clone(),
16860 summary: SessionHistorySummaryV1::from(&preview),
16861 });
16862 Ok(NodeResponse::SessionRecordPreviewed {
16863 record_id,
16864 preview,
16865 })
16866 }
16867 NodeRequest::AcquireController { lease_ms } => {
16868 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16869 Ok(NodeResponse::Controller { controller: Some(controller) })
16870 }
16871 NodeRequest::ReleaseController => {
16872 shared.release_controller(connection_id);
16873 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16874 }
16875 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16876 shared.require_controller(connection_id, role)?;
16877 let root = require_windows_path(root)?;
16878 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16879 let workspace = shared.register_workspace(workspace_id, root).await?;
16880 Ok(NodeResponse::WorkspaceRegistered { workspace })
16881 }
16882 NodeRequest::CreateStandaloneWorkspace {
16883 workspace_id,
16884 root,
16885 initial_branch,
16886 } => {
16887 shared.require_controller(connection_id, role)?;
16888 let root = require_windows_path(root)?;
16889 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16890 let workspace = shared
16891 .create_standalone_workspace(workspace_id, root, initial_branch)
16892 .await?;
16893 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16894 }
16895 NodeRequest::UnregisterWorkspace { workspace_id } => {
16896 shared.require_controller(connection_id, role)?;
16897 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16898 shared.unregister_workspace(&workspace_id)?;
16899 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16900 }
16901 NodeRequest::CreateWorktree {
16902 source_workspace_id,
16903 workspace_id,
16904 target_root,
16905 branch,
16906 base,
16907 } => {
16908 shared.require_controller(connection_id, role)?;
16909 let target_root = require_windows_path(target_root)?;
16910 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16911 let (worktree, workspace) = shared
16912 .create_worktree(
16913 source_workspace_id,
16914 workspace_id,
16915 target_root,
16916 branch,
16917 base,
16918 )
16919 .await?;
16920 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16921 }
16922 NodeRequest::RemoveWorktree {
16923 source_workspace_id,
16924 target_root,
16925 } => {
16926 shared.require_controller(connection_id, role)?;
16927 let native_target_root = require_windows_path(target_root.clone())?;
16928 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16929 let workspace_id = shared
16930 .remove_worktree(source_workspace_id, native_target_root)
16931 .await?;
16932 Ok(NodeResponse::WorktreeRemoved {
16933 target_root,
16934 workspace_id,
16935 })
16936 }
16937 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16938 shared.require_controller(connection_id, role)?;
16939 if !terminal_size.is_valid() {
16940 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16941 }
16942 if let Some(prompt) = initial_prompt.as_deref() {
16943 validate_node_text("spawn initial prompt", prompt)?;
16944 }
16945 match shared
16946 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16947 .await
16948 {
16949 Ok(session) => {
16950 shared.mark_spawn_provisional(session.clone());
16951 Ok(NodeResponse::SpawnAccepted { session })
16952 }
16953 Err(err) => {
16954 tracing::warn!(
16955 %provider,
16956 %workspace_id,
16957 ?mode,
16958 code = ?err.code,
16959 message = %err.message,
16960 "session spawn refused"
16961 );
16962 Err(err)
16963 }
16964 }
16965 }
16966 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16967 shared.require_controller(connection_id, role)?;
16968 if !terminal_size.is_valid() {
16969 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16970 }
16971 if let Some(prompt) = initial_prompt.as_deref() {
16972 validate_node_text("spawn initial prompt", prompt)?;
16973 }
16974 let plan = backend_paths::plan_launch_options(
16975 provider.as_str(),
16976 mode,
16977 options.model.is_some(),
16978 options.effort.is_some(),
16979 )
16980 .map_err(|r| {
16981 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16982 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16983 })?;
16984 let selection = spawn_selection(&options)?;
16985 let pty_selection = match plan {
16986 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16987 _ => None,
16988 };
16989 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16990 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16991 SPAWN_SESSION_OPTIONS
16992 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16993 .await
16994 }).await
16995 }).await;
16996 match spawned {
16997 Ok(session) => {
16998 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16999 shared
17000 .pending_acp_options
17001 .lock()
17002 .unwrap_or_else(|p| p.into_inner())
17003 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17004 }
17005 shared.mark_spawn_provisional(session.clone());
17006 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17007 Ok(NodeResponse::SpawnAccepted { session })
17008 }
17009 Err(err) => {
17010 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17011 Err(err)
17012 }
17013 }
17014 }
17015 NodeRequest::SpawnSpec { spec } => {
17016 shared.require_controller(connection_id, role)?;
17017 let receipt = shared.spawn_from_spec(spec).await?;
17018 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17019 }
17020 NodeRequest::SpawnManagedWorktree { request } => {
17021 shared.require_controller(connection_id, role)?;
17022 let receipt = shared.spawn_managed_worktree(request).await?;
17023 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17024 }
17025 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17026 shared.require_controller(connection_id, role)?;
17027 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17028 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17029 }
17030 NodeRequest::CleanupManagedWorktree { lease_id } => {
17031 shared.require_controller(connection_id, role)?;
17032 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17033 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17034 }
17035 NodeRequest::DiscoverHistory { session, limit } => {
17036 controlled_session(shared, connection_id, role, &session)?;
17037 let candidates = shared.discover_history(&session, limit).await?;
17038 Ok(NodeResponse::HistoryDiscovered {
17039 session,
17040 candidates,
17041 })
17042 }
17043 NodeRequest::LoadHistory {
17044 session,
17045 candidate_id,
17046 } => {
17047 controlled_session(shared, connection_id, role, &session)?;
17048 let loaded = shared
17049 .load_history(&session, candidate_id)
17050 .await?;
17051 Ok(NodeResponse::HistoryLoaded {
17052 session,
17053 session_id: loaded.session_id,
17054 message_count: loaded.message_count,
17055 completed_turn_count: loaded.completed_turn_count,
17056 })
17057 }
17058 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17059 shared.require_controller(connection_id, role)?;
17060 let context = shared
17061 .export_context_pack_for_session_record(&record_id, &session, false)
17062 .await?;
17063 Ok(NodeResponse::ContextPackForSessionRecordExported {
17064 record_id,
17065 session,
17066 context,
17067 })
17068 }
17069 NodeRequest::ExportContextPack { session } => {
17070 controlled_session(shared, connection_id, role, &session)?;
17071 let context = shared.export_context_pack(&session).await?;
17072 Ok(NodeResponse::ContextPackExported { context })
17073 }
17074 NodeRequest::ForgetContextPack { context_id } => {
17075 shared.require_controller(connection_id, role)?;
17076 shared.forget_context_pack(&context_id)?;
17077 Ok(NodeResponse::ContextPackForgotten { context_id })
17078 }
17079 NodeRequest::ResolveDurableContextPack { context_id } => {
17080 let context = shared
17084 .context_catalog
17085 .read()
17086 .unwrap_or_else(|poisoned| poisoned.into_inner())
17087 .get(&context_id)
17088 .map(|pack| pack.receipt().clone())
17089 .ok_or_else(|| {
17090 failure(
17091 NodeFailureCode::UnknownContextPack,
17092 "context pack is not present",
17093 )
17094 })?;
17095 Ok(NodeResponse::DurableContextPackResolved { context })
17096 }
17097 NodeRequest::ReadContextPack { digest } => {
17098 let (id, byte_len, bytes) = shared
17103 .context_catalog
17104 .read()
17105 .unwrap_or_else(|poisoned| poisoned.into_inner())
17106 .get_by_digest(&digest)
17107 .map(|pack| {
17108 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17109 })
17110 .ok_or_else(|| {
17111 failure(
17112 NodeFailureCode::UnknownContextPack,
17113 &format!("context pack {digest} not on this node"),
17114 )
17115 })?;
17116 tracing::debug!(
17117 digest = digest.as_str(),
17118 byte_len,
17119 "read context pack bytes",
17120 );
17121 Ok(NodeResponse::ContextPackBytesRead {
17122 pack: ContextPackBytesRead {
17123 digest,
17124 id,
17125 byte_len,
17126 bytes,
17127 },
17128 })
17129 }
17130 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17131 let provider = controlled_session(shared, connection_id, role, &session)?;
17132 if let Some(prompt) = initial_prompt.as_deref() {
17133 validate_node_text("resume initial prompt", prompt)?;
17134 }
17135 let runtime_requirement = if initial_prompt.is_some() {
17136 ProviderRuntimeRequirement::ResumeWithPrompt
17137 } else {
17138 ProviderRuntimeRequirement::Resume
17139 };
17140 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17141 let runtime_policy = shared
17142 .admit_provider_runtime(&provider, runtime_requirement)
17143 .await?;
17144 let working_directory = shared.bound_workspace_root(&session)?;
17145 let request = ResumeLaunchRequest {
17146 working_directory,
17147 terminal_size,
17148 initial_prompt,
17149 };
17150 request
17151 .validate()
17152 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17153 let command = shared.prepare_command(ControlCommand::Resume {
17154 instance_id: session.session.instance_id,
17155 target: ResumeTarget::CurrentProvider,
17156 runtime_policy,
17157 request,
17158 });
17159 shared.arm_resume(&session, command.id, runtime_policy)?;
17160 let dispatch = shared.dispatch_envelope(command);
17161 if let Err(error) = dispatch {
17162 shared.clear_armed_resume(&session);
17163 return Err(error);
17164 }
17165 Ok(NodeResponse::Accepted)
17166 }
17167 NodeRequest::RenameSessionRecord {
17168 record_id,
17169 display_name,
17170 } => {
17171 shared.require_controller(connection_id, role)?;
17172 let record = shared.rename_session_record(&record_id, display_name)?;
17173 Ok(NodeResponse::SessionRecordUpdated { record })
17174 }
17175 NodeRequest::SetSessionTask {
17176 record_id,
17177 expected_revision,
17178 target,
17179 } => {
17180 shared.require_controller(connection_id, role)?;
17181 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17182 Ok(NodeResponse::SessionRecordUpdated { record })
17183 }
17184 NodeRequest::IndexProviderSession {
17185 workspace_id,
17186 provider,
17187 identity,
17188 display_name,
17189 } => {
17190 shared.require_controller(connection_id, role)?;
17191 let record = shared.index_provider_session(
17192 workspace_id,
17193 provider,
17194 identity,
17195 display_name,
17196 )?;
17197 Ok(NodeResponse::ProviderSessionIndexed { record })
17198 }
17199 NodeRequest::ResumeSessionRecord {
17200 record_id,
17201 terminal_size,
17202 initial_prompt,
17203 } => {
17204 shared.require_controller(connection_id, role)?;
17205 if let Some(prompt) = initial_prompt.as_deref() {
17206 validate_node_text("managed resume initial prompt", prompt)?;
17207 }
17208 let (record, session) = shared
17209 .resume_session_record(
17210 &record_id,
17211 terminal_size,
17212 initial_prompt,
17213 )
17214 .await?;
17215 Ok(NodeResponse::SessionRecordResumed { record, session })
17216 }
17217 NodeRequest::ForgetSessionRecord { record_id } => {
17218 shared.require_controller(connection_id, role)?;
17219 shared.forget_session_record(&record_id).await?;
17220 Ok(NodeResponse::SessionRecordForgotten { record_id })
17221 }
17222 NodeRequest::Prompt { session, text } => {
17223 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17224 validate_node_text("prompt", &text)?;
17225 shared.require_session_runtime_policy(
17226 &session,
17227 ProviderRuntimeRequirement::SemanticPrompt,
17228 )?;
17229 let framing = prompt_framing(&agent_id);
17230 shared
17231 .dispatch_input_bounded(
17232 &session,
17233 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17234 )
17235 .await?;
17236 Ok(NodeResponse::Accepted)
17237 }
17238 NodeRequest::Paste { session, text } => {
17239 controlled_session(shared, connection_id, role, &session)?;
17240 validate_node_text("paste", &text)?;
17241 shared.require_session_runtime_policy(
17242 &session,
17243 ProviderRuntimeRequirement::SemanticPrompt,
17244 )?;
17245 shared
17246 .dispatch_input_bounded(
17247 &session,
17248 InputAction::InsertDraft(PromptPayload {
17249 text,
17250 framing: PromptFraming::BracketedPaste,
17251 }),
17252 )
17253 .await?;
17254 Ok(NodeResponse::Accepted)
17255 }
17256 NodeRequest::Input { session, text } => {
17257 controlled_session(shared, connection_id, role, &session)?;
17258 validate_node_text("terminal input", &text)?;
17259 shared
17260 .dispatch_input_bounded(
17261 &session,
17262 InputAction::TerminalText(TerminalText { text }),
17263 )
17264 .await?;
17265 Ok(NodeResponse::Accepted)
17266 }
17267 NodeRequest::TerminalBytes { session, bytes } => {
17268 controlled_session(shared, connection_id, role, &session)?;
17269 shared.sessionize_spawn(session.session.instance_id);
17270 let action = terminal_bytes_action(bytes)?;
17271 shared
17272 .dispatch_input_bounded(&session, action)
17273 .await?;
17274 Ok(NodeResponse::Accepted)
17275 }
17276 NodeRequest::TerminalControl { session, control } => {
17277 controlled_session(shared, connection_id, role, &session)?;
17278 shared
17279 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17280 .await?;
17281 Ok(NodeResponse::Accepted)
17282 }
17283 NodeRequest::Resize { session, size } => {
17284 controlled_session(shared, connection_id, role, &session)?;
17285 shared.sessionize_spawn(session.session.instance_id);
17286 if !size.is_valid() {
17287 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17288 }
17289 shared.dispatch_resize_bounded(&session, size).await?;
17290 Ok(NodeResponse::Accepted)
17291 }
17292 NodeRequest::Interrupt { session } => {
17293 controlled_session(shared, connection_id, role, &session)?;
17294 shared
17295 .dispatch_input_bounded(
17296 &session,
17297 InputAction::TerminalControl(TerminalControl::Interrupt),
17298 )
17299 .await?;
17300 Ok(NodeResponse::Accepted)
17301 }
17302 NodeRequest::Stop { session, force } => {
17303 controlled_session(shared, connection_id, role, &session)?;
17304 shared.sessionize_spawn(session.session.instance_id);
17305 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17306 shared.retire_session_grok_leader(session.session.instance_id);
17307 Ok(NodeResponse::Accepted)
17308 }
17309 NodeRequest::Remove { session } => {
17310 controlled_session(shared, connection_id, role, &session)?;
17311 shared.remove_session(&session).await?;
17312 Ok(NodeResponse::Accepted)
17313 }
17314 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17315 shared.require_controller(connection_id, role)?;
17316 let snapshot = shared.require_acp_session(&session)?;
17317 let interaction = snapshot
17318 .provider
17319 .interactions
17320 .iter()
17321 .find(|interaction| {
17322 interaction_correlation(
17323 session.session.instance_id,
17324 session.session.generation,
17325 interaction.id.0,
17326 ) == correlation_id
17327 })
17328 .ok_or_else(|| failure(
17329 NodeFailureCode::InvalidRequest,
17330 "unknown interaction correlation",
17331 ))?;
17332 if interaction.status != ProviderInteractionStatus::Pending {
17333 return Err(failure(
17334 NodeFailureCode::BackendOperationFailed,
17335 "interaction has already been resolved",
17336 ));
17337 }
17338 response
17339 .validate_for(interaction.interaction_kind)
17340 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17341 let interaction_id = interaction.id;
17342 let response_kind = response.kind();
17343 let response_outcome = response.outcome();
17344 shared.dispatch(ControlCommand::ResolveInteraction {
17345 instance_id: session.session.instance_id,
17346 generation: session.session.generation,
17347 interaction_id,
17348 response,
17349 })?;
17350 tracing::info!(
17356 node_id = %shared.node_id,
17357 session = ?session.session,
17358 correlation_id = %correlation_id,
17359 interaction_id = interaction_id.0,
17360 response_kind = ?response_kind,
17361 outcome = ?response_outcome,
17362 "interaction resolved",
17363 );
17364 Ok(NodeResponse::Accepted)
17365 }
17366 NodeRequest::SetSessionMode { session, mode_id } => {
17367 shared.require_controller(connection_id, role)?;
17368 shared.require_acp_session(&session)?;
17369 shared.dispatch(ControlCommand::SetSessionMode {
17370 instance_id: session.session.instance_id,
17371 mode_id,
17372 })?;
17373 Ok(NodeResponse::Accepted)
17374 }
17375 NodeRequest::SetSessionConfigOption {
17376 session,
17377 option_id,
17378 value_json,
17379 } => {
17380 shared.require_controller(connection_id, role)?;
17381 shared.require_acp_session(&session)?;
17382 shared.dispatch(ControlCommand::SetSessionConfigOption {
17383 instance_id: session.session.instance_id,
17384 option_id,
17385 value_json,
17386 })?;
17387 Ok(NodeResponse::Accepted)
17388 }
17389 NodeRequest::SetSessionModel { session, model_id } => {
17390 shared.require_controller(connection_id, role)?;
17391 shared.require_acp_session(&session)?;
17392 shared.dispatch(ControlCommand::SetSessionModel {
17393 instance_id: session.session.instance_id,
17394 model_id,
17395 })?;
17396 Ok(NodeResponse::Accepted)
17397 }
17398 NodeRequest::Shutdown => {
17399 shared.require_controller(connection_id, role)?;
17400 shared.begin_shutdown_locked().await?;
17401 Ok(NodeResponse::ShuttingDown)
17402 }
17403 }
17404}
17405
17406fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17407 shared.require_controller(connection_id, role)?;
17408 shared.validate_address(session)
17409}
17410
17411fn select_history_candidate(
17417 candidates: &[HistoryCandidateSummary],
17418 provider_session_id: Option<&str>,
17419) -> Result<String, NodeFailure> {
17420 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17421 Some(id) => candidate.session_id_hint == id,
17422 None => true,
17423 });
17424 let candidate = matches.next().ok_or_else(|| {
17425 failure(
17426 NodeFailureCode::SessionRecordConflict,
17427 if provider_session_id.is_some() {
17428 "no history candidate matches the managed provider session"
17429 } else {
17430 "no history candidate exists for the managed session"
17431 },
17432 )
17433 })?;
17434 if matches.next().is_some() {
17435 return Err(failure(
17436 NodeFailureCode::SessionRecordConflict,
17437 if provider_session_id.is_some() {
17438 "history identity is ambiguous for the managed provider session"
17439 } else {
17440 "history is ambiguous without a verified provider session identity"
17441 },
17442 ));
17443 }
17444 Ok(candidate.id.clone())
17445}
17446
17447fn resolved_spawn_profile_summary(
17448 profile: &SpawnProfileDefaults,
17449 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17450) -> Option<SpawnProfileSummary> {
17451 let environment_profile = match profile.environment_profile_id.as_ref() {
17452 None => None,
17453 Some(profile_id) => {
17454 let binding = environment_profiles.get(profile_id)?;
17455 if binding.provider != profile.provider
17456 || binding.native_profile_id(profile.mode).is_none()
17457 {
17458 return None;
17459 }
17460 Some(ResolvedEnvironmentProfileReceipt {
17461 profile_id: binding.id.clone(),
17462 profile_revision: binding.revision.clone(),
17463 network_allowlist: None,
17464 browser_profile_id: None,
17465 })
17466 }
17467 };
17468 Some(SpawnProfileSummary {
17469 id: profile.profile_id.clone(),
17470 revision: profile.revision.clone(),
17471 environment_profile,
17472 })
17473}
17474
17475fn session_record_export_target_matches(
17494 expected: &ManagedSessionRecord,
17495 current: &ManagedSessionRecord,
17496 session: &SessionAddress,
17497 allow_clean_detachment: bool,
17498) -> bool {
17499 if current == expected && current.active_session.as_ref() == Some(session) {
17500 return current.state == ManagedSessionState::Live;
17501 }
17502 allow_clean_detachment
17503 && current.state == ManagedSessionState::Dormant
17504 && current.active_session.is_none()
17505 && current.record_id == expected.record_id
17506 && current.display_name == expected.display_name
17507 && current.provider == expected.provider
17508 && current.mode == expected.mode
17509 && current.workspace_id == expected.workspace_id
17510 && current.canonical_root == expected.canonical_root
17511 && current.provider_session == expected.provider_session
17512 && current.environment_profile == expected.environment_profile
17513 && current.bundle == expected.bundle
17514 && current.context_id == expected.context_id
17515 && current.context == expected.context
17516 && current.exported_context == expected.exported_context
17517 && current.task_binding == expected.task_binding
17518 && current.created_at_unix_ms == expected.created_at_unix_ms
17519 && current.last_error == expected.last_error
17520}
17521
17522fn session_record_context_export_binding_is_exact(
17523 expected: &ManagedSessionRecord,
17524 current: &ManagedSessionRecord,
17525 identity: Option<&ProviderSessionIdentity>,
17526 session: &SessionAddress,
17527 binding: &SessionBinding,
17528 provider: &AgentId,
17529 current_root: &str,
17530 allow_clean_detachment: bool,
17531) -> bool {
17532 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17533 && current.provider_session.as_ref() == identity
17534 && current.workspace_id == session.workspace_id
17535 && provider == ¤t.provider
17536 && binding.workspace_id == session.workspace_id
17537 && binding.generation == session.session.generation
17538 && binding.record_id.as_ref() == Some(¤t.record_id)
17539 && platform::roots_equal(
17540 current_root,
17541 windows_path_text(¤t.canonical_root),
17542 )
17543}
17544
17545fn session_record_context_export_source_is_usable(
17546 record: &ManagedSessionRecord,
17547 status: &SessionStatus,
17548 allow_clean_detachment: bool,
17549) -> bool {
17550 (record.state == ManagedSessionState::Live
17551 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17552 && context_pack_source_status_is_usable(status)
17553}
17554
17555fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17556 PromptFraming::BracketedPaste
17557}
17558
17559fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17560 if text.len() > MAX_NODE_TEXT_BYTES {
17561 return Err(failure(
17562 NodeFailureCode::InvalidRequest,
17563 &format!(
17564 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17565 text.len(),
17566 ),
17567 ));
17568 }
17569 Ok(())
17570}
17571
17572fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17573 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17574 return Err(failure(
17575 NodeFailureCode::InvalidRequest,
17576 &format!(
17577 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17578 bytes.len(),
17579 ),
17580 ));
17581 }
17582 Ok(())
17583}
17584
17585fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17586 validate_terminal_bytes(&bytes)?;
17587 Ok(InputAction::TerminalBytes(bytes))
17588}
17589
17590fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17591 if root.is_empty()
17592 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17593 || root.chars().any(char::is_control)
17594 {
17595 return Err(failure(
17596 NodeFailureCode::InvalidWorkspaceRoot,
17597 &format!(
17598 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17599 ),
17600 ));
17601 }
17602 Ok(())
17603}
17604
17605fn canonical_standalone_workspace_candidate(
17606 workspace_id: &WorkspaceId,
17607 root: &str,
17608) -> Result<String, NodeServerError> {
17609 let path = Path::new(root);
17610 match std::fs::symlink_metadata(path) {
17611 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17612 .map(|workspace| workspace.canonical_root),
17613 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17614 if !path.is_absolute() {
17615 return Err(NodeServerError::InvalidWorkspaceRoot {
17616 workspace_id: workspace_id.clone(),
17617 path: root.to_owned(),
17618 message: "path is not absolute".to_owned(),
17619 });
17620 }
17621 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17622 workspace_id: workspace_id.clone(),
17623 path: root.to_owned(),
17624 message: "path has no standalone directory name".to_owned(),
17625 })?;
17626 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17627 workspace_id: workspace_id.clone(),
17628 path: root.to_owned(),
17629 message: "path has no parent directory".to_owned(),
17630 })?;
17631 let canonical = std::fs::canonicalize(parent)
17632 .map(|parent| parent.join(name))
17633 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17634 workspace_id: workspace_id.clone(),
17635 path: root.to_owned(),
17636 message: error.to_string(),
17637 })?;
17638 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17639 NodeServerError::InvalidWorkspaceRoot {
17640 workspace_id: workspace_id.clone(),
17641 path: path.to_string_lossy().into_owned(),
17642 message: "canonical path is not valid Unicode".to_owned(),
17643 }
17644 })?;
17645 let canonical_root = platform::normalize_canonical_root(canonical_root);
17646 validate_workspace_root(workspace_id, &canonical_root)?;
17647 Ok(canonical_root)
17648 }
17649 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17650 workspace_id: workspace_id.clone(),
17651 path: root.to_owned(),
17652 message: error.to_string(),
17653 }),
17654 }
17655}
17656
17657fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17658 let Some(revision) = revision else {
17659 return Ok(());
17660 };
17661 if revision.is_empty()
17662 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17663 || revision.chars().any(char::is_control)
17664 {
17665 return Err(failure(
17666 NodeFailureCode::InvalidRequest,
17667 &format!(
17668 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17669 ),
17670 ));
17671 }
17672 Ok(())
17673}
17674
17675fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17676 let code = match error.kind {
17677 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17678 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17679 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17680 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17681 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17682 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17683 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17684 };
17685 failure(code, &error.message)
17686}
17687
17688fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17689 let code = match error.kind {
17690 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17691 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17692 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17693 NodeFailureCode::BackendOperationFailed
17694 }
17695 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17696 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17697 }
17698 };
17699 failure(code, error.message)
17700}
17701
17702fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17703 let (code, message) = match error.kind {
17704 GitWorktreeErrorKind::Invalid => (
17705 NodeFailureCode::InvalidRequest,
17706 "managed Git input is invalid",
17707 ),
17708 GitWorktreeErrorKind::NotRepository => (
17709 NodeFailureCode::NotGitRepository,
17710 "managed source is not a Git repository",
17711 ),
17712 GitWorktreeErrorKind::Conflict => (
17713 NodeFailureCode::WorktreeConflict,
17714 "managed Git identity conflicts with existing repository state",
17715 ),
17716 GitWorktreeErrorKind::Protected => (
17717 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17718 "managed worktree ownership validation failed",
17719 ),
17720 GitWorktreeErrorKind::Dirty => (
17721 NodeFailureCode::WorktreeDirty,
17722 "managed worktree contains uncommitted changes",
17723 ),
17724 GitWorktreeErrorKind::Locked => (
17725 NodeFailureCode::WorktreeLocked,
17726 "managed worktree is locked",
17727 ),
17728 GitWorktreeErrorKind::Failed => (
17729 NodeFailureCode::BackendOperationFailed,
17730 "managed Git operation failed",
17731 ),
17732 };
17733 NodeFailure { code, message: message.to_owned() }
17734}
17735
17736fn managed_spawn_receipt(
17737 resolved: &ResolvedSpawnSpec,
17738 incarnation_id: NodeIncarnationId,
17739 session: SessionAddress,
17740 lease: ManagedWorktreeLeaseSnapshot,
17741 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17742 bundle: Option<ResolvedBundleReceipt>,
17743 context: Option<ResolvedContextPackReceipt>,
17744) -> ManagedWorktreeSpawnReceipt {
17745 let mut managed = resolved.clone();
17746 managed.target.worktree_id = Some(lease.workspace_id.clone());
17747 ManagedWorktreeSpawnReceipt {
17748 spawn: managed.receipt_with_materialization(
17749 incarnation_id,
17750 session,
17751 environment_profile,
17752 bundle,
17753 context,
17754 ),
17755 lease,
17756 }
17757}
17758
17759fn validate_selected_worktree(
17760 source_root: &str,
17761 selected_root: &str,
17762 worktrees: &[NativeGitWorktreeSnapshot],
17763) -> Result<(), NodeFailure> {
17764 if !worktrees
17765 .iter()
17766 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17767 {
17768 return Err(failure(
17769 NodeFailureCode::WorktreeProtected,
17770 "source workspace is not an authoritative non-bare Git worktree root",
17771 ));
17772 }
17773 if !worktrees.iter().any(|worktree| {
17774 worktree_paths_equal(&worktree.path, selected_root)
17775 && !worktree.is_main
17776 && !worktree.is_bare
17777 }) {
17778 return Err(failure(
17779 NodeFailureCode::WorktreeProtected,
17780 "selected workspace is not an eligible Git worktree in the source repository",
17781 ));
17782 }
17783 Ok(())
17784}
17785
17786fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17787 let code = match error.kind() {
17788 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17789 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17790 NodeFailureCode::RepositoryPathUnsafe
17791 }
17792 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17793 WorkspaceFileReadErrorKind::NotRegularFile => {
17794 NodeFailureCode::RepositoryFileNotRegular
17795 }
17796 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17797 NodeFailureCode::RepositoryFileReadFailed
17798 }
17799 WorkspaceFileReadErrorKind::RevisionConflict => {
17800 NodeFailureCode::RepositoryFileRevisionConflict
17801 }
17802 WorkspaceFileReadErrorKind::AlreadyExists
17803 | WorkspaceFileReadErrorKind::ParentNotFound
17804 | WorkspaceFileReadErrorKind::ParentNotDirectory
17805 | WorkspaceFileReadErrorKind::Canceled => {
17806 NodeFailureCode::RepositoryFileReadFailed
17807 }
17808 };
17809 failure(code, "repository file read failed")
17810}
17811
17812fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17813 let code = match error.kind() {
17814 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17815 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17816 NodeFailureCode::RepositoryPathUnsafe
17817 }
17818 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17819 WorkspaceFileReadErrorKind::NotRegularFile => {
17820 NodeFailureCode::RepositoryFileNotRegular
17821 }
17822 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17823 NodeFailureCode::RepositoryFileWriteFailed
17824 }
17825 WorkspaceFileReadErrorKind::RevisionConflict => {
17826 NodeFailureCode::RepositoryFileRevisionConflict
17827 }
17828 WorkspaceFileReadErrorKind::AlreadyExists
17829 | WorkspaceFileReadErrorKind::ParentNotFound
17830 | WorkspaceFileReadErrorKind::ParentNotDirectory
17831 | WorkspaceFileReadErrorKind::Canceled => {
17832 NodeFailureCode::RepositoryFileWriteFailed
17833 }
17834 };
17835 failure(code, "repository file write failed")
17836}
17837
17838async fn settle_workspace_entry_create<T>(
17839 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17840 commit_state: Arc<AtomicU8>,
17841 deadline: Duration,
17842 timeout_message: &'static str,
17843 task_failure_message: &'static str,
17844) -> Result<T, NodeFailure> {
17845 let joined = match timeout(deadline, &mut task).await {
17846 Ok(joined) => joined,
17847 Err(_) => {
17848 if commit_state
17849 .compare_exchange(
17850 WORKSPACE_ENTRY_CREATE_PENDING,
17851 WORKSPACE_ENTRY_CREATE_CANCELED,
17852 Ordering::AcqRel,
17853 Ordering::Acquire,
17854 )
17855 .is_ok()
17856 {
17857 return Err(failure(
17858 NodeFailureCode::RepositoryEntryCreateTimedOut,
17859 timeout_message,
17860 ));
17861 }
17862 debug_assert_eq!(
17863 commit_state.load(Ordering::Acquire),
17864 WORKSPACE_ENTRY_CREATE_COMMITTING,
17865 );
17866 task.await
17867 }
17868 };
17869 joined
17870 .map_err(|_| {
17871 failure(
17872 NodeFailureCode::RepositoryEntryCreateFailed,
17873 task_failure_message,
17874 )
17875 })?
17876 .map_err(workspace_entry_create_failure)
17877}
17878
17879fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17880 let code = match error.kind() {
17881 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17882 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17883 NodeFailureCode::RepositoryPathUnsafe
17884 }
17885 WorkspaceFileReadErrorKind::AlreadyExists => {
17886 NodeFailureCode::RepositoryEntryAlreadyExists
17887 }
17888 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17889 NodeFailureCode::RepositoryParentNotFound
17890 }
17891 WorkspaceFileReadErrorKind::ParentNotDirectory
17892 | WorkspaceFileReadErrorKind::NotRegularFile => {
17893 NodeFailureCode::RepositoryParentNotDirectory
17894 }
17895 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17896 NodeFailureCode::RepositoryEntryCreateFailed
17897 }
17898 WorkspaceFileReadErrorKind::RevisionConflict => {
17899 NodeFailureCode::RepositoryEntryCreateFailed
17900 }
17901 WorkspaceFileReadErrorKind::Canceled => {
17902 NodeFailureCode::RepositoryEntryCreateTimedOut
17903 }
17904 };
17905 failure(code, "repository entry create failed")
17906}
17907
17908fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17909 let value = digest(&SHA256, bytes)
17910 .as_ref()
17911 .iter()
17912 .map(|byte| format!("{byte:02x}"))
17913 .collect();
17914 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17915}
17916
17917fn managed_spawn_request_digest_v2(
17918 request: &ManagedWorktreeSpawnRequestV2,
17919) -> Result<String, NodeFailure> {
17920 let encoded = serde_json::to_vec(request).map_err(|_| {
17921 failure(
17922 NodeFailureCode::InvalidRequest,
17923 "managed worktree V2 request could not be canonicalized",
17924 )
17925 })?;
17926 let mut value = String::with_capacity(71);
17927 value.push_str("sha256:");
17928 for byte in digest(&SHA256, &encoded).as_ref() {
17929 use std::fmt::Write as _;
17930 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17931 }
17932 Ok(value)
17933}
17934
17935fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17936 let code = match error {
17937 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17938 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17939 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17940 NativeSessionPreviewError::UnsupportedProvider
17941 | NativeSessionPreviewError::PreviewUnavailable
17942 | NativeSessionPreviewError::SessionNotFound
17943 | NativeSessionPreviewError::AmbiguousSession => {
17944 NodeFailureCode::BackendOperationFailed
17945 }
17946 };
17947 failure(code, "native session preview failed")
17948}
17949
17950fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17951 let code = match error.kind() {
17952 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17953 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17954 };
17955 failure(code, "host directory browse failed")
17956}
17957
17958fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17959 let code = match error {
17960 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17961 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17962 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17963 NodeFailureCode::DeliveryStageConflict
17964 }
17965 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17966 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17967 NodeFailureCode::DeliveryChunkOutOfOrder
17968 }
17969 DeliveryStoreError::BlobDigestMismatch => {
17970 NodeFailureCode::DeliveryBlobDigestMismatch
17971 }
17972 DeliveryStoreError::BundleDigestMismatch => {
17973 NodeFailureCode::DeliveryBundleDigestMismatch
17974 }
17975 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17976 DeliveryStoreError::Unavailable
17977 | DeliveryStoreError::Corrupt
17978 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17979 };
17980 failure(code, "delivery operation failed")
17981}
17982
17983fn validate_workspace_root(
17984 workspace_id: &WorkspaceId,
17985 canonical_root: &str,
17986) -> Result<(), NodeServerError> {
17987 #[cfg(windows)]
17988 if canonical_root.starts_with(r"\\") {
17989 return Err(NodeServerError::InvalidWorkspaceRoot {
17990 workspace_id: workspace_id.clone(),
17991 path: canonical_root.to_owned(),
17992 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17993 });
17994 }
17995 if !platform::workspace_root_supported(canonical_root)
17996 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17997 {
17998 return Err(NodeServerError::InvalidWorkspaceRoot {
17999 workspace_id: workspace_id.clone(),
18000 path: canonical_root.to_owned(),
18001 message: "canonical path must be absolute, bounded, and free of control characters"
18002 .to_owned(),
18003 });
18004 }
18005 Ok(())
18006}
18007
18008fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18009 NodeFailure {
18010 code,
18011 message: node_failure_category(code).to_owned(),
18012 }
18013}
18014
18015fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18016 let code = match error {
18017 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18018 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18019 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18020 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18021 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18022 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18023 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18024 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18025 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18026 };
18027 failure(code, "harness MCP proxy operation failed")
18028}
18029
18030static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18034
18035fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18042 let trace = launch.trace.as_ref()?;
18043 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18044 if trace_dir.is_empty() {
18045 return None;
18046 }
18047 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18048 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18049 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18050}
18051
18052fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18053 let remaining = deadline.saturating_duration_since(Instant::now());
18054 if remaining.is_zero() {
18055 Err(failure(
18056 NodeFailureCode::SpawnDeadlineExceeded,
18057 "spawn deadline elapsed",
18058 ))
18059 } else {
18060 Ok(remaining)
18061 }
18062}
18063
18064fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18065 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18066 match deadline {
18067 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18068 None => Ok(bounded),
18069 }
18070}
18071
18072fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18073 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18074 failure(
18075 NodeFailureCode::SpawnDeadlineExceeded,
18076 "spawn dispatch exceeded the spawn deadline",
18077 )
18078 } else {
18079 error
18080 }
18081}
18082
18083fn spawn_runtime_capabilities(
18084 capabilities: &SpawnRequiredCapabilities,
18085) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18086 capabilities
18087 .iter()
18088 .map(|capability| match capability.as_str() {
18089 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18090 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18091 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18092 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18093 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18094 }
18095 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18096 _ => Err(failure(
18097 NodeFailureCode::UnsupportedSpawnCapability,
18098 "spawn required capability is unknown",
18099 )),
18100 })
18101 .collect()
18102}
18103
18104fn persistence_failure(_error: io::Error) -> NodeFailure {
18105 NodeFailure {
18106 code: NodeFailureCode::BackendOperationFailed,
18107 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18108 }
18109}
18110
18111fn node_failure_category(code: NodeFailureCode) -> &'static str {
18112 match code {
18113 NodeFailureCode::InvalidRequest => "invalid-request",
18114 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18115 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18116 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18117 NodeFailureCode::ReservationConflict => "reservation-conflict",
18118 NodeFailureCode::ReservationExpired => "reservation-expired",
18119 NodeFailureCode::BindingMismatch => "binding-mismatch",
18120 NodeFailureCode::NotActivated => "not-activated",
18121 NodeFailureCode::CallNotFound => "call-not-found",
18122 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18123 NodeFailureCode::ResponseTooLarge => "response-too-large",
18124 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18125 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18126 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18127 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18128 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18129 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18130 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18131 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18132 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18133 NodeFailureCode::Unauthorized => "unauthorized",
18134 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18135 NodeFailureCode::ControllerBusy => "controller-busy",
18136 NodeFailureCode::ControllerRequired => "controller-required",
18137 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18138 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18139 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18140 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18141 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18142 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18143 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18144 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18145 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18146 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18147 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18148 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18149 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18150 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18151 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18152 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18153 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18154 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18155 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18156 NodeFailureCode::GitReadFailed => "git-read-failed",
18157 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18158 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18159 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18160 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18161 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18162 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18163 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18164 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18165 NodeFailureCode::TurnInFlight => "turn-in-flight",
18166 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18167 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18168 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18169 "unsupported-network-allowlist-mapping"
18170 }
18171 NodeFailureCode::BrowserStationProbeUnavailable => {
18172 "browser-station-probe-unavailable"
18173 }
18174 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18175 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18176 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18177 "environment-profile-binding-mismatch"
18178 }
18179 NodeFailureCode::UnknownBundle => "unknown-bundle",
18180 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18181 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18182 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18183 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18184 NodeFailureCode::ContextPackMaterializationFailed => {
18185 "context-pack-materialization-failed"
18186 }
18187 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18188 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18189 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18190 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18191 NodeFailureCode::LastWorkspace => "last-workspace",
18192 NodeFailureCode::NotGitRepository => "not-git-repository",
18193 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18194 NodeFailureCode::WorktreeProtected => "worktree-protected",
18195 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18196 NodeFailureCode::WorktreeLocked => "worktree-locked",
18197 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18198 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18199 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18200 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18201 "managed-worktree-profile-revision-mismatch"
18202 }
18203 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18204 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18205 "standalone-workspace-recovery-required"
18206 }
18207 NodeFailureCode::UnknownSession => "unknown-session",
18208 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18209 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18210 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18211 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18212 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18213 NodeFailureCode::WorkspaceRegistrationRequired => {
18214 "workspace-registration-required"
18215 }
18216 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18217 NodeFailureCode::StaleGeneration => "stale-generation",
18218 NodeFailureCode::BackendBusy => "backend-busy",
18219 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18220 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18221 NodeFailureCode::ShuttingDown => "shutting-down",
18222 }
18223}
18224
18225fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18226 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18227}
18228
18229fn durable_state_load_error(error: io::Error) -> NodeServerError {
18230 let category = match session_registry::state_load_refusal(&error) {
18231 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18232 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18233 }
18234 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18235 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18236 }
18237 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18238 DURABLE_STATE_CONFLICT_ERROR
18239 }
18240 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18241 };
18242 durable_state_server_error(error, category)
18243}
18244
18245fn unix_time_ms() -> u64 {
18246 std::time::SystemTime::now()
18247 .duration_since(std::time::UNIX_EPOCH)
18248 .unwrap_or_default()
18249 .as_millis()
18250 .min(u64::MAX as u128) as u64
18251}
18252
18253fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18254 if record.provider_session.is_some() {
18255 ManagedSessionState::Dormant
18256 } else {
18257 ManagedSessionState::Unavailable
18258 }
18259}
18260
18261fn active_record_state(
18262 identity_admitted: bool,
18263 provider_session_is_known: bool,
18264) -> ManagedSessionState {
18265 if identity_admitted && !provider_session_is_known {
18266 ManagedSessionState::IdentityPending
18267 } else {
18268 ManagedSessionState::Live
18269 }
18270}
18271
18272fn managed_worktree_record_holders(
18273 records: &[ManagedSessionRecord],
18274) -> Vec<(SessionRecordId, WorkspaceId)> {
18275 records
18276 .iter()
18277 .filter(|record| record.provider_session.is_some())
18278 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18279 .collect()
18280}
18281
18282fn managed_spawn_record_ownership(
18283 binding: &SessionBinding,
18284 effective_runtime_policy: ProviderRuntimePolicy,
18285) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18286 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18287 if effective_runtime_policy.provider_session_identity {
18288 (binding.record_id.clone(), None)
18289 } else {
18290 (None, binding.record_id.clone())
18291 }
18292}
18293
18294#[derive(Debug, Error)]
18295pub enum NodeServerError {
18296 #[error("node endpoint must be a bounded absolute local transport path")]
18297 InvalidEndpoint,
18298 #[error("node access token must contain 1..=4096 bytes")]
18299 InvalidAccessToken,
18300 #[error("node HTTP observer must listen on a loopback address: {0}")]
18301 InvalidApiListen(std::net::SocketAddr),
18302 #[error("node browser bridge must listen on a loopback address: {0}")]
18303 InvalidBridgeListen(std::net::SocketAddr),
18304 #[error("node browser bridge token must contain 1..=4096 bytes")]
18305 InvalidBridgeToken,
18306 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18307 BridgeUnderlayRequiresBridgeListen,
18308 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18309 BridgeUnderlayRequiresBridgeToken,
18310 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18311 InvalidBridgeUnderlayToken,
18312 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18313 InvalidBridgeUnderlayKey,
18314 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18315 InvalidCallHome(std::net::SocketAddr),
18316 #[error("node could not reach its call-home relay: {0}")]
18317 CallHomeTransport(#[source] std::io::Error),
18318 #[error("node durable state path must be an absolute file path: {0}")]
18319 InvalidStatePath(String),
18320 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18321 InvalidHarnessMcpHelper,
18322 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18323 InvalidNetworkAllowlistCatalogPath(String),
18324 #[error("network allowlist catalog could not be read: {0}")]
18325 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18326 #[error("network allowlist catalog line {line}: {message}")]
18327 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18328 #[error("network allowlist catalog: {0}")]
18329 InvalidNetworkAllowlistCatalog(String),
18330 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18331 NetworkAllowlistCatalogCapacity { max: usize },
18332 #[error("the local state directory is unavailable; supply an explicit state path")]
18333 LocalStateDirectoryUnavailable,
18334 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18335 LocalRuntimeDirectoryUnavailable,
18336 #[error("node durable state failed: {0}")]
18337 DurableState(io::Error),
18338 #[error("node requires at least one configured workspace")]
18339 NoWorkspaces,
18340 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18341 InvalidWorkspaceRoot {
18342 workspace_id: WorkspaceId,
18343 path: String,
18344 message: String,
18345 },
18346 #[error("duplicate workspace ID: {0}")]
18347 DuplicateWorkspaceId(WorkspaceId),
18348 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18349 DuplicateWorkspaceRoot {
18350 first: WorkspaceId,
18351 second: WorkspaceId,
18352 root: String,
18353 },
18354 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18355 ManagedWorktreeProfileRequired(WorkspaceId),
18356 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18357 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18358 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18359 DuplicateManagedWorktreeProfile {
18360 workspace_id: WorkspaceId,
18361 profile_id: WorktreeProfileId,
18362 },
18363 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18364 ManagedWorktreeProfileCapacity {
18365 workspace_id: WorkspaceId,
18366 max: usize,
18367 },
18368 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18369 InvalidManagedWorktreeProfile {
18370 workspace_id: WorkspaceId,
18371 profile_id: WorktreeProfileId,
18372 message: String,
18373 },
18374 #[error("duplicate node environment profile: {0}")]
18375 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18376 #[error("node environment profile provider is unavailable: {0}")]
18377 EnvironmentProfileProviderUnavailable(AgentId),
18378 #[error("node environment profile capacity is {max}")]
18379 EnvironmentProfileCapacity { max: usize },
18380 #[error("node environment profiles reuse native profile ID '{0}'")]
18381 DuplicateNativeEnvironmentProfile(String),
18382 #[error("node native environment profile is invalid: {0}")]
18383 NativeEnvironmentProfile(String),
18384 #[error("node session-environment materialization root must be absolute")]
18385 InvalidSessionEnvironmentRoot,
18386 #[error("node session-environment materializer is unavailable")]
18387 SessionEnvironmentMaterializerRequired,
18388 #[error("node session-environment materializer failed to initialize")]
18389 SessionEnvironmentMaterializer,
18390 #[error("node bundle catalog is invalid: {0}")]
18391 BundleCatalog(String),
18392 #[error("node delivery store failed to initialize: {0}")]
18393 DeliveryStore(String),
18394 #[error("node context pack store failed to initialize")]
18395 ContextPackStore,
18396 #[error("active agent registry failed: {0}")]
18397 Registry(String),
18398 #[error("node provider contract manifest is invalid: {0}")]
18399 ProviderContractManifest(String),
18400 #[error("named pipe I/O failed: {0}")]
18401 Io(#[from] io::Error),
18402 #[error("node HTTP observer failed: {0}")]
18403 HttpApi(io::Error),
18404 #[error("node browser bridge failed: {0}")]
18405 BridgeApi(io::Error),
18406 #[error(transparent)]
18407 Frame(#[from] FrameError),
18408 #[error("node handshake failed: {0}")]
18409 Handshake(String),
18410 #[error("node authentication frame was not received before the bounded deadline")]
18411 AuthenticationTimedOut,
18412 #[error("node authentication primitive failed: {0}")]
18413 Authentication(String),
18414 #[error("node incarnation identity generation failed: {0}")]
18415 IncarnationIdentity(String),
18416 #[error("node pre-authentication limiter is closed")]
18417 PreauthClosed,
18418 #[error("node authenticated connection limit was reached")]
18419 AuthenticatedConnectionLimit,
18420 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18421 SignalStreamClosed,
18422 #[error("node stop-all dispatch failed: {0:?}")]
18423 ShutdownDispatch(NodeFailure),
18424 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18425 ShutdownTimedOut { active_native_sessions: usize },
18426}
18427
18428impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18429 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18430 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18431 match error {
18432 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18433 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18434 E::InvalidEntry { line, message } => {
18435 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18436 }
18437 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18438 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18439 }
18440 }
18441}
18442
18443#[cfg(test)]
18444mod agent_stream_blocked_tests {
18445 use super::*;
18446 use gate4agent_types::{
18447 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18448 ProviderSource,
18449 };
18450
18451 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18452 ProviderSource {
18453 family,
18454 binding: AdapterBinding::new(
18455 AdapterId::new(adapter).unwrap(),
18456 "fixture/v1",
18457 AdapterVerification::SyntheticFixture,
18458 )
18459 .unwrap(),
18460 }
18461 }
18462
18463 fn provider_control_event_at(
18464 family: AdapterFamily,
18465 adapter: &str,
18466 source_sequence: u64,
18467 provider_event: ProviderEvent,
18468 ) -> ControlEvent {
18469 ControlEvent {
18470 sequence: 41,
18471 command_id: None,
18472 instance_id: AgentInstanceId(7),
18473 generation: SessionGeneration(3),
18474 event: ControlEventKind::ProviderEvent {
18475 sequence: 8,
18476 source: provider_source(family, adapter),
18477 source_sequence,
18478 event: provider_event,
18479 },
18480 }
18481 }
18482
18483 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18484 provider_control_event_at(family, "fixture", 9, provider_event)
18485 }
18486
18487 fn denied_host_request(
18488 by: ProviderHostDecisionAuthority,
18489 reason: Option<String>,
18490 ) -> ControlEvent {
18491 provider_control_event_at(
18492 AdapterFamily::Acp,
18493 "claude-code",
18494 2,
18495 ProviderEvent::HostRequestObserved {
18496 method: "terminal/create".to_owned(),
18497 params_json: String::new(),
18498 decision: ProviderHostRequestDecision::Denied { by },
18499 outcome: ProviderHostRequestOutcome::Executed,
18500 reason,
18501 },
18502 )
18503 }
18504
18505 fn blocked(
18506 event: &ControlEvent,
18507 ) -> (
18508 Option<String>,
18509 String,
18510 BlockAuthorityV1,
18511 Option<String>,
18512 String,
18513 Option<String>,
18514 ) {
18515 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18516 chunk.validate().expect("minted chunk must validate");
18517 let AgentStreamChunkKindV1::Blocked {
18518 correlation_id,
18519 tool_class,
18520 authority,
18521 reason_kind,
18522 reason,
18523 help,
18524 } = chunk.kind
18525 else {
18526 panic!("expected AgentStreamChunkKindV1::Blocked");
18527 };
18528 (correlation_id, tool_class, authority, reason_kind, reason, help)
18529 }
18530
18531 #[test]
18535 fn the_enabled_provider_matrix_is_exact() {
18536 let registry = active_registry().unwrap();
18537 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18538 assert_eq!(
18539 providers
18540 .iter()
18541 .map(|contract| contract.provider.as_str())
18542 .collect::<Vec<_>>(),
18543 vec!["shell", "claude", "codex", "grok", "kimi"],
18545 );
18546 assert!(adapters.iter().all(|contract| {
18547 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18548 }));
18549 }
18550
18551 #[test]
18554 fn gate_denied_host_request_mints_a_blocked_chunk() {
18555 let gate_text =
18556 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18557 let event = denied_host_request(
18558 ProviderHostDecisionAuthority::Gate,
18559 Some(gate_text.to_owned()),
18560 );
18561 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18562 assert_eq!(chunk.source_sequence, 2);
18563 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18564 assert_eq!(authority, BlockAuthorityV1::HostGate);
18565 assert_eq!(reason, gate_text);
18566 assert_eq!(correlation_id, None);
18567 assert_eq!(reason_kind, None);
18568 assert_eq!(help, None);
18569 assert_eq!(tool_class, "Write");
18570 }
18571
18572 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.";
18573
18574 #[test]
18580 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18581 let event = provider_control_event(
18582 AdapterFamily::Acp,
18583 ProviderEvent::ToolCompleted {
18584 id: "toolu_9".to_owned(),
18585 output: CLASSIFIER_SAMPLE.to_owned(),
18586 is_error: true,
18587 duration_ms: None,
18588 agent_id: None,
18589 non_execution_kind: Some("permission-rule".to_owned()),
18590 },
18591 );
18592 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18593 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18594 assert_eq!(reason, "Blocked by classifier");
18595 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18596 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18597 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18598 }
18599
18600 #[test]
18605 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18606 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.";
18607 let event = provider_control_event(
18608 AdapterFamily::Acp,
18609 ProviderEvent::SessionEnded {
18610 result: "JSON-RPC error -32603: Internal error".to_owned(),
18611 cost_usd: None,
18612 is_error: true,
18613 stop_reason: Some(ProviderStopReason::ProviderError {
18614 code: -32603,
18615 message: message.to_owned(),
18616 vendor_code: Some("usageLimitExceeded".to_owned()),
18617 }),
18618 },
18619 );
18620 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18621 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18622 assert_eq!(reason, message);
18623 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18624 assert_eq!(tool_class, "Session");
18625 }
18626
18627 #[test]
18630 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18631 let event = provider_control_event(
18632 AdapterFamily::Acp,
18633 ProviderEvent::SessionEnded {
18634 result: "refusal".to_owned(),
18635 cost_usd: None,
18636 is_error: true,
18637 stop_reason: Some(ProviderStopReason::Refusal),
18638 },
18639 );
18640 let (_, _, authority, _, _, _) = blocked(&event);
18641 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18642 }
18643
18644 #[test]
18648 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18649 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18650 let event = provider_control_event(
18651 AdapterFamily::Acp,
18652 ProviderEvent::ToolCompleted {
18653 id: "toolu_1".to_owned(),
18654 output: "command exited with status 1".to_owned(),
18655 is_error: true,
18656 duration_ms: Some(12),
18657 agent_id: None,
18658 non_execution_kind,
18659 },
18660 );
18661 assert!(agent_stream_chunk(&event).is_none());
18662 }
18663 let narrative = provider_control_event(
18664 AdapterFamily::Acp,
18665 ProviderEvent::ToolCompleted {
18666 id: "toolu_2".to_owned(),
18667 output: "the harness blocked the write, approval needed".to_owned(),
18668 is_error: false,
18669 duration_ms: Some(3),
18670 agent_id: None,
18671 non_execution_kind: None,
18672 },
18673 );
18674 assert!(agent_stream_chunk(&narrative).is_none());
18675 }
18676
18677 #[test]
18681 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18682 for (decision, outcome) in [
18683 (
18684 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18685 ProviderHostRequestOutcome::Executed,
18686 ),
18687 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18688 (
18689 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18690 ProviderHostRequestOutcome::Failed {
18691 error: "terminal/create spawn failed: os error 3".to_owned(),
18692 },
18693 ),
18694 ] {
18695 let event = provider_control_event_at(
18696 AdapterFamily::Acp,
18697 "claude-code",
18698 3,
18699 ProviderEvent::HostRequestObserved {
18700 method: "fs/read_text_file".to_owned(),
18701 params_json: String::new(),
18702 decision: decision.clone(),
18703 outcome,
18704 reason: None,
18705 },
18706 );
18707 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18708 }
18709 }
18710
18711 #[test]
18715 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18716 for (by, expected_authority, expected_reason) in [
18717 (
18718 ProviderHostDecisionAuthority::Operator,
18719 BlockAuthorityV1::Operator,
18720 "declined by the operator",
18721 ),
18722 (
18723 ProviderHostDecisionAuthority::DeadlinePolicy,
18724 BlockAuthorityV1::HostDeadline,
18725 "denied by host policy after the operator deadline expired",
18726 ),
18727 ] {
18728 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18729 assert_eq!(authority, expected_authority);
18730 assert_eq!(reason, expected_reason);
18731 }
18732 }
18733
18734 #[test]
18738 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18739 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18740 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18741 ProviderHostDecisionAuthority::Policy,
18742 Some(oversized),
18743 ));
18744 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18745 }
18746}
18747
18748#[cfg(test)]
18749mod standalone_capability_platform_tests {
18750 use super::*;
18751
18752 #[test]
18753 fn standalone_workspace_capability_requires_windows_identity_guards() {
18754 let capabilities = baseline_capabilities().unwrap();
18755 let advertised = capabilities.iter().any(|capability| {
18756 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18757 });
18758 assert_eq!(advertised, cfg!(windows));
18759
18760 let request = NodeRequest::CreateStandaloneWorkspace {
18761 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18762 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18763 initial_branch: None,
18764 };
18765 assert_eq!(
18766 request_uses_unnegotiated_capability(&request, &capabilities),
18767 !cfg!(windows),
18768 );
18769 }
18770}
18771
18772
18773#[cfg(test)]
18774mod station_knobs_resolve_tests {
18775 use super::*;
18776 use gate4agent_catalog::EnvMutation;
18777 use gate4agent_runtime_native::{
18778 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18779 NativeLaunchProfileId,
18780 };
18781 use std::ffi::OsString;
18782 use std::sync::Arc;
18783
18784 struct EmptyEnvironmentResolver;
18785
18786 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18787 fn resolve_child_environment(
18788 &self,
18789 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18790 Ok(vec![EnvMutation {
18791 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18792 value: None,
18793 }])
18794 }
18795 }
18796
18797 fn agent(value: &str) -> AgentId {
18798 AgentId::new(value).unwrap()
18799 }
18800
18801 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18802 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18803 let profile_revision =
18804 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18805 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18806 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18807 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18808 provider: agent("claude"),
18809 mode: SessionMode::Pty,
18810 terminal_size: gate4agent_types::TerminalSize {
18811 rows: 24,
18812 columns: 80,
18813 },
18814 prompt: None,
18815 bundle_id: None,
18816 context_id: None,
18817 environment_profile_id: Some(profile_id.clone()),
18818 };
18819 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18820 let catalog = active_registry().unwrap();
18821 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18822 let control = runtime.native_launch_profile_control();
18823 let node_profile = NodeEnvironmentProfile::new(
18824 profile_id.clone(),
18825 profile_revision,
18826 agent("claude"),
18827 [NativeLaunchProfile::new(
18828 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18829 agent("claude"),
18830 TransportKind::Pty,
18831 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18832 Arc::new(EmptyEnvironmentResolver),
18833 )
18834 .unwrap()],
18835 )
18836 .unwrap();
18837 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18838 for native_profile in native_profiles {
18839 runtime.upsert_native_launch_profile(native_profile).unwrap();
18840 }
18841 let workspace = WorkspaceConfig::new(
18842 WorkspaceId::new("primary").unwrap(),
18843 std::env::current_dir().unwrap(),
18844 )
18845 .unwrap();
18846 let shared = NodeShared::new_with_incarnation(
18847 handle,
18848 "fixture-token".to_owned(),
18849 NodeId::new("node-station-knobs-test").unwrap(),
18850 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18851 vec![workspace],
18852 vec![agent("claude")],
18853 ProviderRuntimeStatuses::default(),
18854 None,
18855 Vec::new(),
18856 Vec::new(),
18857 spawn_profiles,
18858 Some(control),
18859 None,
18860 None,
18861 None,
18862 Vec::new(),
18863 Vec::new(),
18864 Vec::new(),
18865 Vec::new(),
18866 None,
18867 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18868 );
18869 shared
18870 .environment_profiles
18871 .write()
18872 .unwrap_or_else(|poisoned| poisoned.into_inner())
18873 .insert(binding.id.clone(), binding);
18874 (shared, spawn_defaults)
18875 }
18876
18877 #[test]
18880 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18881 let (shared, defaults) = station_knobs_fixture();
18882 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18883 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18884 let spec = crate::protocol::SpawnSpec {
18885 target: crate::protocol::SpawnTarget {
18886 node_id: shared.node_id.clone(),
18887 workspace_id: WorkspaceId::new("primary").unwrap(),
18888 worktree_id: None,
18889 },
18890 profile_id: defaults.profile_id.clone(),
18891 expected_profile_revision: defaults.revision.clone(),
18892 overrides: crate::protocol::SpawnOverrides {
18893 network_allowlist: Some(unknown),
18894 browser_profile_id: Some(browser),
18895 ..crate::protocol::SpawnOverrides::default()
18896 },
18897 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18898 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18899 "station-knobs-unknown-allowlist",
18900 )
18901 .unwrap(),
18902 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18903 };
18904 let resolved = spec.resolve(&defaults).unwrap();
18905 let failure = shared
18906 .resolve_environment_profile(&resolved)
18907 .expect_err("unknown allowlist must refuse");
18908 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18909 }
18910
18911 #[test]
18917 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18918 let (shared, defaults) = station_knobs_fixture();
18919 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18920 #[cfg(not(feature = "dig2-station-probe"))]
18921 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18922 shared
18923 .network_allowlist_catalog
18924 .write()
18925 .unwrap_or_else(|poisoned| poisoned.into_inner())
18926 .insert_id(allowlist.clone())
18927 .unwrap();
18928 let spec = crate::protocol::SpawnSpec {
18929 target: crate::protocol::SpawnTarget {
18930 node_id: shared.node_id.clone(),
18931 workspace_id: WorkspaceId::new("primary").unwrap(),
18932 worktree_id: None,
18933 },
18934 profile_id: defaults.profile_id.clone(),
18935 expected_profile_revision: defaults.revision.clone(),
18936 overrides: crate::protocol::SpawnOverrides {
18937 network_allowlist: Some(allowlist.clone()),
18938 #[cfg(not(feature = "dig2-station-probe"))]
18939 browser_profile_id: Some(browser.clone()),
18940 ..crate::protocol::SpawnOverrides::default()
18941 },
18942 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18943 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18944 .unwrap(),
18945 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18946 };
18947 let resolved = spec.resolve(&defaults).unwrap();
18948 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18949 #[cfg(not(feature = "dig2-station-probe"))]
18950 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18951 let receipt = shared
18952 .resolve_environment_profile(&resolved)
18953 .expect("resolve ok")
18954 .expect("env profile present");
18955 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18956 #[cfg(not(feature = "dig2-station-probe"))]
18957 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18958 #[cfg(feature = "dig2-station-probe")]
18959 assert_eq!(receipt.browser_profile_id, None);
18960 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18961
18962 let omitted = crate::protocol::SpawnSpec {
18963 overrides: crate::protocol::SpawnOverrides::default(),
18964 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18965 .unwrap(),
18966 ..spec
18967 };
18968 let resolved_omit = omitted.resolve(&defaults).unwrap();
18969 let bare = shared
18970 .resolve_environment_profile(&resolved_omit)
18971 .expect("resolve ok")
18972 .expect("env profile present");
18973 assert_eq!(bare.network_allowlist, None);
18974 assert_eq!(bare.browser_profile_id, None);
18975 }
18976
18977 #[cfg(feature = "dig2-station-probe")]
18982 #[test]
18983 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18984 let (shared, defaults) = station_knobs_fixture();
18985 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18986 let spec = crate::protocol::SpawnSpec {
18987 target: crate::protocol::SpawnTarget {
18988 node_id: shared.node_id.clone(),
18989 workspace_id: WorkspaceId::new("primary").unwrap(),
18990 worktree_id: None,
18991 },
18992 profile_id: defaults.profile_id.clone(),
18993 expected_profile_revision: defaults.revision.clone(),
18994 overrides: crate::protocol::SpawnOverrides {
18995 browser_profile_id: Some(browser),
18996 ..crate::protocol::SpawnOverrides::default()
18997 },
18998 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18999 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19000 "station-knobs-probe-refuse",
19001 )
19002 .unwrap(),
19003 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19004 };
19005 let resolved = spec.resolve(&defaults).unwrap();
19006 let failure = shared
19007 .resolve_environment_profile(&resolved)
19008 .expect_err("browser_profile with failing probe must refuse");
19009 #[cfg(windows)]
19010 {
19011 assert!(
19012 matches!(
19013 failure.code,
19014 NodeFailureCode::BrowserStationUnreachable
19015 | NodeFailureCode::BrowserStationProbeUnavailable
19016 ),
19017 "unexpected code {:?}",
19018 failure.code
19019 );
19020 }
19021 #[cfg(not(windows))]
19022 {
19023 assert_eq!(
19024 failure.code,
19025 NodeFailureCode::BrowserStationProbeUnavailable
19026 );
19027 }
19028 }
19029
19030 #[cfg(feature = "dig2-station-probe")]
19032 #[test]
19033 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19034 let (shared, defaults) = station_knobs_fixture();
19035 let spec = crate::protocol::SpawnSpec {
19036 target: crate::protocol::SpawnTarget {
19037 node_id: shared.node_id.clone(),
19038 workspace_id: WorkspaceId::new("primary").unwrap(),
19039 worktree_id: None,
19040 },
19041 profile_id: defaults.profile_id.clone(),
19042 expected_profile_revision: defaults.revision.clone(),
19043 overrides: crate::protocol::SpawnOverrides::default(),
19044 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19045 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19046 "station-knobs-probe-omit",
19047 )
19048 .unwrap(),
19049 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19050 };
19051 let resolved = spec.resolve(&defaults).unwrap();
19052 let receipt = shared
19053 .resolve_environment_profile(&resolved)
19054 .expect("omit browser_profile must not probe")
19055 .expect("env profile present");
19056 assert_eq!(receipt.browser_profile_id, None);
19057 }
19058
19059 #[cfg(feature = "dig2-station-probe")]
19063 #[test]
19064 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19065 let (shared, _defaults) = station_knobs_fixture();
19066 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19067 let workspace_id = WorkspaceId::new("primary").unwrap();
19068 let address_a = SessionAddress {
19069 workspace_id: workspace_id.clone(),
19070 session: SessionKey {
19071 instance_id: AgentInstanceId(101),
19072 generation: SessionGeneration(1),
19073 },
19074 };
19075 let address_b = SessionAddress {
19076 workspace_id: workspace_id.clone(),
19077 session: SessionKey {
19078 instance_id: AgentInstanceId(102),
19079 generation: SessionGeneration(1),
19080 },
19081 };
19082 let guard_a = crate::dig2_station_lease::acquire_lease(
19083 &shared.browser_station_leases,
19084 profile.clone(),
19085 address_a.session.instance_id,
19086 1,
19087 )
19088 .expect("first lease");
19089 let busy = crate::dig2_station_lease::acquire_lease(
19090 &shared.browser_station_leases,
19091 profile.clone(),
19092 address_b.session.instance_id,
19093 2,
19094 )
19095 .expect_err("second holder must refuse");
19096 assert!(matches!(
19097 busy,
19098 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19099 ));
19100 shared.bind_session_with_policy(
19102 &address_a,
19103 ProviderRuntimePolicy::raw_pty(),
19104 None,
19105 );
19106 guard_a.retain();
19107 assert!(shared.remove_binding(&address_a).is_some());
19108 crate::dig2_station_lease::acquire_lease(
19109 &shared.browser_station_leases,
19110 profile,
19111 address_b.session.instance_id,
19112 3,
19113 )
19114 .expect("reacquire after clear")
19115 .retain();
19116 assert!(
19117 crate::dig2_station_lease::release_lease_for_holder(
19118 &shared.browser_station_leases,
19119 address_b.session.instance_id,
19120 )
19121 );
19122 }
19123
19124 #[cfg(not(feature = "dig2-station-probe"))]
19127 #[test]
19128 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19129 let (shared, defaults) = station_knobs_fixture();
19130 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19131 let spec = crate::protocol::SpawnSpec {
19132 target: crate::protocol::SpawnTarget {
19133 node_id: shared.node_id.clone(),
19134 workspace_id: WorkspaceId::new("primary").unwrap(),
19135 worktree_id: None,
19136 },
19137 profile_id: defaults.profile_id.clone(),
19138 expected_profile_revision: defaults.revision.clone(),
19139 overrides: crate::protocol::SpawnOverrides {
19140 browser_profile_id: Some(browser.clone()),
19141 ..crate::protocol::SpawnOverrides::default()
19142 },
19143 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19144 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19145 .unwrap(),
19146 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19147 };
19148 let resolved = spec.resolve(&defaults).unwrap();
19149 let receipt = shared
19150 .resolve_environment_profile(&resolved)
19151 .expect("stub resolve")
19152 .expect("env profile");
19153 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19154 }
19155
19156
19157 #[test]
19160 fn network_allowlist_catalog_loads_from_temp_file() {
19161 let unique = std::time::SystemTime::now()
19162 .duration_since(std::time::UNIX_EPOCH)
19163 .unwrap()
19164 .as_nanos();
19165 let path = std::env::temp_dir().join(format!(
19166 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19167 std::process::id(),
19168 ));
19169 std::fs::write(
19170 &path,
19171 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19172 )
19173 .unwrap();
19174 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19175 .expect("temp catalog file must load");
19176 let _ = std::fs::remove_file(&path);
19177 assert_eq!(loaded.len(), 2);
19178 let (shared, defaults) = station_knobs_fixture();
19179 {
19180 let mut catalog = shared
19181 .network_allowlist_catalog
19182 .write()
19183 .unwrap_or_else(|poisoned| poisoned.into_inner());
19184 *catalog = loaded;
19185 }
19186 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19187 let spec = crate::protocol::SpawnSpec {
19188 target: crate::protocol::SpawnTarget {
19189 node_id: shared.node_id.clone(),
19190 workspace_id: WorkspaceId::new("primary").unwrap(),
19191 worktree_id: None,
19192 },
19193 profile_id: defaults.profile_id.clone(),
19194 expected_profile_revision: defaults.revision.clone(),
19195 overrides: crate::protocol::SpawnOverrides {
19196 network_allowlist: Some(allowlist.clone()),
19197 ..crate::protocol::SpawnOverrides::default()
19198 },
19199 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19200 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19201 "station-knobs-catalog-file",
19202 )
19203 .unwrap(),
19204 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19205 };
19206 let resolved = spec.resolve(&defaults).unwrap();
19207 let receipt = shared
19208 .resolve_environment_profile(&resolved)
19209 .expect("registered via file load")
19210 .expect("env profile present");
19211 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19212 }
19213
19214 #[test]
19217 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19218 let (shared, _defaults) = station_knobs_fixture();
19219 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19220 shared
19221 .network_allowlist_catalog
19222 .write()
19223 .unwrap_or_else(|poisoned| poisoned.into_inner())
19224 .insert_id(allowlist.clone())
19225 .unwrap();
19226 let snapshot = shared.snapshot();
19227 let inventory = snapshot
19228 .launch_inventory
19229 .expect("launch inventory present");
19230 let ids = inventory
19231 .network_allowlists
19232 .expect("non-empty catalog surfaces ids");
19233 assert_eq!(ids, vec![allowlist]);
19234 }
19235
19236 #[test]
19240 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19241 let (shared, defaults) = station_knobs_fixture();
19242 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19243 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19244 id: allowlist.clone(),
19245 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19246 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19247 peer: "127.0.0.1:443".to_owned(),
19248 }],
19249 provider_native: Some(
19250 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19251 codex_network_access: Some(true),
19252 },
19253 ),
19254 };
19255 shared
19256 .network_allowlist_catalog
19257 .write()
19258 .unwrap_or_else(|poisoned| poisoned.into_inner())
19259 .insert(entry)
19260 .unwrap();
19261 let spec = crate::protocol::SpawnSpec {
19262 target: crate::protocol::SpawnTarget {
19263 node_id: shared.node_id.clone(),
19264 workspace_id: WorkspaceId::new("primary").unwrap(),
19265 worktree_id: None,
19266 },
19267 profile_id: defaults.profile_id.clone(),
19268 expected_profile_revision: defaults.revision.clone(),
19269 overrides: crate::protocol::SpawnOverrides {
19270 network_allowlist: Some(allowlist),
19271 ..crate::protocol::SpawnOverrides::default()
19272 },
19273 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19274 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19275 "station-knobs-unsupported-mapping",
19276 )
19277 .unwrap(),
19278 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19279 };
19280 let resolved = spec.resolve(&defaults).unwrap();
19281 assert_eq!(resolved.provider.as_str(), "claude");
19282 let failure = shared
19283 .resolve_environment_profile(&resolved)
19284 .expect_err("claude + codex_network_access must refuse");
19285 assert_eq!(
19286 failure.code,
19287 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19288 );
19289 }
19290
19291 #[test]
19295 fn resolve_codex_network_access_overlay_moderate_only() {
19296 let (shared, defaults) = station_knobs_fixture();
19297 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19298 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19299 id: allowlist.clone(),
19300 permits: Vec::new(),
19301 provider_native: Some(
19302 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19303 codex_network_access: Some(true),
19304 },
19305 ),
19306 };
19307 shared
19308 .network_allowlist_catalog
19309 .write()
19310 .unwrap_or_else(|poisoned| poisoned.into_inner())
19311 .insert(entry)
19312 .unwrap();
19313
19314 let overlay = shared
19315 .resolve_codex_network_access_overlay_args(
19316 &agent("codex"),
19317 SessionMode::Pty,
19318 ApprovalLevel::Moderate,
19319 Some(&allowlist),
19320 )
19321 .expect("Moderate Codex maps network_access");
19322 assert_eq!(
19323 overlay,
19324 vec![
19325 std::ffi::OsString::from("-c"),
19326 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19327 ]
19328 );
19329
19330 let acp = shared
19331 .resolve_codex_network_access_overlay_args(
19332 &agent("codex"),
19333 SessionMode::Acp,
19334 ApprovalLevel::Moderate,
19335 Some(&allowlist),
19336 )
19337 .unwrap();
19338 assert_eq!(acp, overlay);
19339
19340 let inline = shared
19341 .resolve_codex_network_access_overlay_args(
19342 &agent("codex"),
19343 SessionMode::Inline,
19344 ApprovalLevel::Moderate,
19345 Some(&allowlist),
19346 )
19347 .unwrap_err();
19348 assert_eq!(
19349 inline.code,
19350 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19351 );
19352
19353 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19354 let spec = crate::protocol::SpawnSpec {
19355 target: crate::protocol::SpawnTarget {
19356 node_id: shared.node_id.clone(),
19357 workspace_id: WorkspaceId::new("primary").unwrap(),
19358 worktree_id: None,
19359 },
19360 profile_id: defaults.profile_id.clone(),
19361 expected_profile_revision: defaults.revision.clone(),
19362 overrides: crate::protocol::SpawnOverrides {
19363 provider: crate::protocol::SpawnOverride::Set {
19364 value: agent("codex"),
19365 },
19366 approval_level: Some(level),
19367 network_allowlist: Some(allowlist.clone()),
19368 ..crate::protocol::SpawnOverrides::default()
19369 },
19370 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19371 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19372 "codex-net-refuse-{}",
19373 match level {
19374 ApprovalLevel::ReadOnly => "ro",
19375 ApprovalLevel::FullAuto => "fa",
19376 _ => "other",
19377 }
19378 ))
19379 .unwrap(),
19380 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19381 };
19382 let resolved = spec.resolve(&defaults).unwrap();
19383 let failure = shared
19384 .resolve_environment_profile(&resolved)
19385 .expect_err("RO/FullAuto must refuse");
19386 assert_eq!(
19387 failure.code,
19388 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19389 );
19390 }
19391 }
19392
19393 #[test]
19397 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19398 let (shared, _defaults) = station_knobs_fixture();
19399 let allowlist =
19400 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19401 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19402 id: allowlist.clone(),
19403 permits: vec![
19404 crate::network_allowlist_catalog::NetworkPermitSketch {
19405 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19406 peer: "github.com:443".to_owned(),
19407 },
19408 crate::network_allowlist_catalog::NetworkPermitSketch {
19409 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19410 peer: "*.npmjs.org".to_owned(),
19411 },
19412 ],
19413 provider_native: None,
19414 };
19415 shared
19416 .network_allowlist_catalog
19417 .write()
19418 .unwrap_or_else(|poisoned| poisoned.into_inner())
19419 .insert(entry)
19420 .unwrap();
19421
19422 let unique = std::time::SystemTime::now()
19423 .duration_since(std::time::UNIX_EPOCH)
19424 .unwrap()
19425 .as_nanos();
19426 let provider_home = std::env::temp_dir().join(format!(
19427 "gate4agent-claude-provider-home-{}-{unique}",
19428 std::process::id(),
19429 ));
19430 std::fs::create_dir_all(&provider_home).unwrap();
19431
19432 let overlay = shared
19433 .resolve_network_allowlist_overlay_args(
19434 &agent("claude"),
19435 SessionMode::Pty,
19436 ApprovalLevel::Moderate,
19437 Some(&allowlist),
19438 Some(provider_home.as_path()),
19439 )
19440 .expect("Claude PTY settings overlay on unix");
19441 let settings_path = provider_home.join(
19442 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19443 );
19444 assert_eq!(
19445 overlay,
19446 vec![
19447 std::ffi::OsString::from("--settings"),
19448 std::ffi::OsString::from(settings_path.as_os_str()),
19449 ]
19450 );
19451 let body = std::fs::read_to_string(&settings_path).unwrap();
19452 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19453 assert_eq!(value["sandbox"]["enabled"], true);
19454 assert_eq!(
19455 value["sandbox"]["network"]["allowedDomains"],
19456 serde_json::json!(["github.com", "*.npmjs.org"])
19457 );
19458 assert!(settings_path.starts_with(&provider_home));
19459
19460 let acp = shared
19461 .resolve_network_allowlist_overlay_args(
19462 &agent("claude"),
19463 SessionMode::Acp,
19464 ApprovalLevel::Moderate,
19465 Some(&allowlist),
19466 Some(provider_home.as_path()),
19467 )
19468 .expect_err("Claude ACP must refuse settings overlay");
19469 assert_eq!(
19470 acp.code,
19471 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19472 );
19473
19474 let no_home = shared
19475 .resolve_network_allowlist_overlay_args(
19476 &agent("claude"),
19477 SessionMode::Pty,
19478 ApprovalLevel::Moderate,
19479 Some(&allowlist),
19480 None,
19481 )
19482 .expect_err("Claude overlay requires ProviderHome");
19483 assert_eq!(
19484 no_home.code,
19485 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19486 );
19487
19488 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19489 shared
19490 .network_allowlist_catalog
19491 .write()
19492 .unwrap_or_else(|poisoned| poisoned.into_inner())
19493 .insert_id(bare.clone())
19494 .unwrap();
19495 let empty = shared
19496 .resolve_network_allowlist_overlay_args(
19497 &agent("claude"),
19498 SessionMode::Pty,
19499 ApprovalLevel::Moderate,
19500 Some(&bare),
19501 Some(provider_home.as_path()),
19502 )
19503 .unwrap();
19504 assert!(empty.is_empty());
19505
19506 let _ = std::fs::remove_dir_all(&provider_home);
19507 }
19508
19509 #[test]
19511 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19512 let (shared, _defaults) = station_knobs_fixture();
19513 let allowlist =
19514 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19515 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19516 id: allowlist.clone(),
19517 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19518 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19519 peer: "example.com:443".to_owned(),
19520 }],
19521 provider_native: None,
19522 };
19523 shared
19524 .network_allowlist_catalog
19525 .write()
19526 .unwrap_or_else(|poisoned| poisoned.into_inner())
19527 .insert(entry)
19528 .unwrap();
19529 for provider in ["kimi", "grok"] {
19530 let overlay = shared
19531 .resolve_network_allowlist_overlay_args(
19532 &agent(provider),
19533 SessionMode::Pty,
19534 ApprovalLevel::Moderate,
19535 Some(&allowlist),
19536 None,
19537 )
19538 .unwrap();
19539 assert!(
19540 overlay.is_empty(),
19541 "{provider} must not invent network argv from permits"
19542 );
19543 }
19544 }
19545
19546 #[test]
19548 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19549 let unique = std::time::SystemTime::now()
19550 .duration_since(std::time::UNIX_EPOCH)
19551 .unwrap()
19552 .as_nanos();
19553 let path = std::env::temp_dir().join(format!(
19554 "gate4agent-node-allowlist-json-{}-{unique}.json",
19555 std::process::id(),
19556 ));
19557 std::fs::write(
19558 &path,
19559 r#"{
19560 "schema_version": 2,
19561 "entries": [
19562 {
19563 "id": "egress-default",
19564 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19565 },
19566 { "id": "lab-egress" }
19567 ]
19568 }"#,
19569 )
19570 .unwrap();
19571 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19572 .expect("json v2 catalog must load");
19573 let _ = std::fs::remove_file(&path);
19574 assert_eq!(loaded.len(), 2);
19575 let entry = loaded
19576 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19577 .unwrap();
19578 assert_eq!(entry.permits.len(), 1);
19579 let (shared, defaults) = station_knobs_fixture();
19580 {
19581 let mut catalog = shared
19582 .network_allowlist_catalog
19583 .write()
19584 .unwrap_or_else(|poisoned| poisoned.into_inner());
19585 *catalog = loaded;
19586 }
19587 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19588 let spec = crate::protocol::SpawnSpec {
19589 target: crate::protocol::SpawnTarget {
19590 node_id: shared.node_id.clone(),
19591 workspace_id: WorkspaceId::new("primary").unwrap(),
19592 worktree_id: None,
19593 },
19594 profile_id: defaults.profile_id.clone(),
19595 expected_profile_revision: defaults.revision.clone(),
19596 overrides: crate::protocol::SpawnOverrides {
19597 network_allowlist: Some(allowlist.clone()),
19598 ..crate::protocol::SpawnOverrides::default()
19599 },
19600 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19601 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19602 "station-knobs-catalog-json",
19603 )
19604 .unwrap(),
19605 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19606 };
19607 let resolved = spec.resolve(&defaults).unwrap();
19608 let receipt = shared
19609 .resolve_environment_profile(&resolved)
19610 .expect("permits-only entry ok for claude")
19611 .expect("env profile present");
19612 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19613 let inventory = shared
19614 .snapshot()
19615 .launch_inventory
19616 .expect("launch inventory present");
19617 let ids = inventory.network_allowlists.expect("ids surface");
19618 assert_eq!(
19619 ids,
19620 vec![
19621 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19622 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19623 ]
19624 );
19625 }
19626
19627
19628}
19629
19630
19631#[cfg(test)]
19632mod workspace_environment_collision_tests {
19633 use super::*;
19634 use crate::session_environment::{
19635 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19636 };
19637 use gate4agent_catalog::EnvMutation;
19638 use gate4agent_runtime_native::{
19639 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19640 NativeLaunchProfileId,
19641 };
19642 use std::ffi::OsString;
19643 use std::sync::Arc;
19644
19645 struct EmptyEnvironmentResolver;
19646
19647 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19648 fn resolve_child_environment(
19649 &self,
19650 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19651 Ok(vec![EnvMutation {
19652 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19653 value: None,
19654 }])
19655 }
19656 }
19657
19658 fn agent(value: &str) -> AgentId {
19659 AgentId::new(value).unwrap()
19660 }
19661
19662 fn claude_config_dir_collision_fixture() -> (
19664 NodeShared,
19665 ResolvedEnvironmentProfileReceipt,
19666 ResolvedEnvironmentProfileReceipt,
19667 ) {
19668 let catalog = active_registry().unwrap();
19669 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19670 let control = runtime.native_launch_profile_control();
19671 let workspace = WorkspaceConfig::new(
19672 WorkspaceId::new("primary").unwrap(),
19673 std::env::current_dir().unwrap(),
19674 )
19675 .unwrap();
19676 let shared = NodeShared::new_with_incarnation(
19677 handle,
19678 "fixture-token".to_owned(),
19679 NodeId::new("node-env-collision-test").unwrap(),
19680 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19681 vec![workspace],
19682 vec![agent("claude")],
19683 ProviderRuntimeStatuses::default(),
19684 None,
19685 Vec::new(),
19686 Vec::new(),
19687 SpawnProfileRegistry::default(),
19688 Some(control),
19689 None,
19690 None,
19691 None,
19692 Vec::new(),
19693 Vec::new(),
19694 Vec::new(),
19695 Vec::new(),
19696 None,
19697 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19698 );
19699
19700 let mut receipts = Vec::new();
19701 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19702 let profile_id =
19703 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19704 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19705 "claude-config-dir-{suffix}-r1"
19706 ))
19707 .unwrap();
19708 let materialization = NodeSessionMaterializationProfile::new(
19709 Vec::new(),
19710 vec![NodeSessionPathBinding::new(
19711 "CLAUDE_CONFIG_DIR",
19712 NodeSessionPathClass::ProviderHome,
19713 )
19714 .unwrap()],
19715 Vec::new(),
19716 )
19717 .unwrap();
19718 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19719 profile_id.clone(),
19720 profile_revision.clone(),
19721 agent("claude"),
19722 [NativeLaunchProfile::new(
19723 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19724 .unwrap(),
19725 agent("claude"),
19726 TransportKind::Pty,
19727 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19728 Arc::new(EmptyEnvironmentResolver),
19729 )
19730 .unwrap()],
19731 Some(materialization),
19732 )
19733 .unwrap();
19734 let (binding, native_profiles, materialization) = node_profile.into_parts();
19735 for native_profile in native_profiles {
19736 runtime.upsert_native_launch_profile(native_profile).unwrap();
19737 }
19738 shared
19739 .environment_profiles
19740 .write()
19741 .unwrap_or_else(|poisoned| poisoned.into_inner())
19742 .insert(binding.id.clone(), binding);
19743 shared
19744 .environment_materialization_profiles
19745 .write()
19746 .unwrap_or_else(|poisoned| poisoned.into_inner())
19747 .insert(profile_id.clone(), materialization.unwrap());
19748 receipts.push(ResolvedEnvironmentProfileReceipt {
19749 profile_id,
19750 profile_revision,
19751 network_allowlist: None,
19752 browser_profile_id: None,
19753 });
19754 }
19755 let profile_b = receipts.pop().unwrap();
19756 let profile_a = receipts.pop().unwrap();
19757 (shared, profile_a, profile_b)
19758 }
19759
19760 #[test]
19761 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19762 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19763 let primary = WorkspaceId::new("primary").unwrap();
19764 let worktree = WorkspaceId::new("worktree-b").unwrap();
19765 let address = SessionAddress {
19766 workspace_id: primary.clone(),
19767 session: SessionKey {
19768 instance_id: AgentInstanceId(41),
19769 generation: SessionGeneration(1),
19770 },
19771 };
19772 shared.bind_session_with_policy(
19773 &address,
19774 ProviderRuntimePolicy::raw_pty(),
19775 Some(profile_a.clone()),
19776 );
19777
19778 let refused = shared
19779 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19780 .unwrap_err();
19781 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19782
19783 let refused_none = shared
19785 .ensure_workspace_environment_profile_compatible(&primary, None)
19786 .unwrap_err();
19787 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19788
19789 shared
19791 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19792 .unwrap();
19793
19794 shared
19796 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19797 .unwrap();
19798 }
19799
19800 #[tokio::test]
19801 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19802 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19803 let primary = WorkspaceId::new("primary").unwrap();
19804 let address = SessionAddress {
19805 workspace_id: primary.clone(),
19806 session: SessionKey {
19807 instance_id: AgentInstanceId(41),
19808 generation: SessionGeneration(1),
19809 },
19810 };
19811 shared.bind_session_with_policy(
19812 &address,
19813 ProviderRuntimePolicy::raw_pty(),
19814 Some(profile_a),
19815 );
19816
19817 let failure = shared
19818 .spawn_session_with_deadline(
19819 primary,
19820 agent("claude"),
19821 SessionMode::Pty,
19822 gate4agent_types::TerminalSize {
19823 rows: 24,
19824 columns: 80,
19825 },
19826 None,
19827 Some(profile_b),
19828 None,
19829 None,
19830 None,
19831 None,
19832 SpawnRecordPolicy::ProviderIdentityOnly,
19833 Some(Instant::now() + Duration::from_secs(30)),
19834 &[],
19835 None,
19836 ApprovalLevel::default(),
19837 None,
19838 None,
19839 )
19840 .await
19841 .unwrap_err();
19842 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19843 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19845 assert!(shared.handle.snapshot().sessions.is_empty());
19846 }
19847}
19848
19849#[cfg(test)]
19850mod spawn_characterization_tests {
19851 use super::*;
19852 use crate::session_environment::{
19853 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19854 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19855 NodeSessionPathClass,
19856 };
19857 use gate4agent_catalog::EnvMutation;
19858 use gate4agent_runtime_native::{
19859 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19860 NativeLaunchProfile, NativeLaunchProfileId,
19861 };
19862 use std::ffi::OsString;
19863 use std::sync::Arc;
19864
19865 struct EmptyEnvironmentResolver;
19866
19867 struct FixtureSecretResolver {
19868 deny: Arc<AtomicBool>,
19869 }
19870
19871 impl NodeSecretResolver for FixtureSecretResolver {
19872 fn resolve(
19873 &self,
19874 _reference: &NodeSecretReference,
19875 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19876 if self.deny.load(Ordering::Acquire) {
19877 Err(NodeSecretResolveError::Denied)
19878 } else {
19879 NodeSecretValue::text("fixture-secret")
19880 .map_err(|_| NodeSecretResolveError::Unavailable)
19881 }
19882 }
19883 }
19884
19885 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19886 fn resolve_child_environment(
19887 &self,
19888 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19889 Ok(vec![EnvMutation {
19890 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19891 value: None,
19892 }])
19893 }
19894 }
19895
19896 fn agent(value: &str) -> AgentId {
19897 AgentId::new(value).unwrap()
19898 }
19899
19900 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19901 let unique = std::time::SystemTime::now()
19902 .duration_since(std::time::UNIX_EPOCH)
19903 .unwrap()
19904 .as_nanos();
19905 std::env::temp_dir().join(format!(
19906 "gate4agent-node-{label}-{}-{unique}",
19907 std::process::id(),
19908 ))
19909 }
19910
19911 fn materialization_test_shared(
19912 deny_secret: bool,
19913 ) -> (
19914 NodeShared,
19915 NativeRuntime,
19916 ResolvedEnvironmentProfileReceipt,
19917 PathBuf,
19918 Arc<AtomicBool>,
19919 ) {
19920 let workspace = WorkspaceConfig::new(
19921 WorkspaceId::new("primary").unwrap(),
19922 std::env::current_dir().unwrap(),
19923 )
19924 .unwrap();
19925 materialization_test_shared_with_workspace(deny_secret, workspace)
19926 }
19927
19928 fn materialization_test_shared_with_workspace(
19929 deny_secret: bool,
19930 workspace: WorkspaceConfig,
19931 ) -> (
19932 NodeShared,
19933 NativeRuntime,
19934 ResolvedEnvironmentProfileReceipt,
19935 PathBuf,
19936 Arc<AtomicBool>,
19937 ) {
19938 let root = temporary_workspace_root(if deny_secret {
19939 "materialization-denied"
19940 } else {
19941 "materialization-ready"
19942 });
19943 std::fs::create_dir_all(&root).unwrap();
19944 let materialization_root = root.join("session-environments");
19945 let deny = Arc::new(AtomicBool::new(deny_secret));
19946 let resolver = Arc::new(FixtureSecretResolver {
19947 deny: Arc::clone(&deny),
19948 });
19949 let materializer = SessionEnvironmentMaterializer::new(
19950 materialization_root,
19951 resolver,
19952 )
19953 .unwrap();
19954 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19955 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19956 "materialized-claude-r1",
19957 )
19958 .unwrap();
19959 let materialization = NodeSessionMaterializationProfile::new(
19960 vec![NodeSessionEnvironmentMutation::SetSecret {
19961 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19962 reference: NodeSecretReference::new("fixture-token").unwrap(),
19963 }],
19964 vec![NodeSessionPathBinding::new(
19965 "GATE4AGENT_PROVIDER_HOME",
19966 NodeSessionPathClass::ProviderHome,
19967 )
19968 .unwrap()],
19969 vec![NodeSessionFile::secret(
19970 NodeSessionPathClass::Config,
19971 "auth/token",
19972 NodeSecretReference::new("fixture-token").unwrap(),
19973 )
19974 .unwrap()],
19975 )
19976 .unwrap();
19977 let catalog = active_registry().unwrap();
19978 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19979 let control = runtime.native_launch_profile_control();
19980 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19981 profile_id.clone(),
19982 profile_revision.clone(),
19983 agent("claude"),
19984 [NativeLaunchProfile::new(
19985 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19986 agent("claude"),
19987 TransportKind::Pty,
19988 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19989 Arc::new(EmptyEnvironmentResolver),
19990 )
19991 .unwrap()],
19992 Some(materialization),
19993 )
19994 .unwrap();
19995 let (binding, native_profiles, materialization) = node_profile.into_parts();
19996 for native_profile in native_profiles {
19997 runtime.upsert_native_launch_profile(native_profile).unwrap();
19998 }
19999 let shared = NodeShared::new_with_incarnation(
20000 handle,
20001 "fixture-token".to_owned(),
20002 NodeId::new("node-materialization-test").unwrap(),
20003 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20004 vec![workspace],
20005 vec![agent("claude")],
20006 ProviderRuntimeStatuses::default(),
20007 None,
20008 Vec::new(),
20009 Vec::new(),
20010 SpawnProfileRegistry::default(),
20011 Some(control),
20012 None,
20013 Some(materializer),
20014 None,
20015 Vec::new(),
20016 Vec::new(),
20017 Vec::new(),
20018 Vec::new(),
20019 None,
20020 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20021 );
20022 shared
20023 .environment_profiles
20024 .write()
20025 .unwrap_or_else(|poisoned| poisoned.into_inner())
20026 .insert(binding.id.clone(), binding);
20027 shared
20028 .environment_materialization_profiles
20029 .write()
20030 .unwrap_or_else(|poisoned| poisoned.into_inner())
20031 .insert(profile_id.clone(), materialization.unwrap());
20032 (
20033 shared,
20034 runtime,
20035 ResolvedEnvironmentProfileReceipt {
20036 profile_id,
20037 profile_revision,
20038 network_allowlist: None,
20039 browser_profile_id: None,
20040 },
20041 root,
20042 deny,
20043 )
20044 }
20045
20046
20047 async fn characterization_spawn(
20050 shared: &NodeShared,
20051 mode: SessionMode,
20052 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20053 required: &[ProviderRuntimeCapability],
20054 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20055 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20056 shared
20057 .spawn_session_with_deadline(
20058 WorkspaceId::new("primary").unwrap(),
20059 agent("claude"),
20060 mode,
20061 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20062 None,
20063 environment_profile,
20064 None,
20065 None,
20066 None,
20067 Some(ProviderRuntimePolicy::raw_pty()),
20068 SpawnRecordPolicy::ProviderIdentityOnly,
20069 Some(Instant::now() + Duration::from_secs(30)),
20070 required,
20071 harness_mcp,
20072 ApprovalLevel::default(),
20073 None,
20074 None,
20075 )
20076 .await
20077 }
20078
20079 fn assert_no_session_state(shared: &NodeShared) {
20080 assert!(shared.handle.snapshot().sessions.is_empty());
20081 assert!(shared
20082 .session_bindings
20083 .lock()
20084 .unwrap_or_else(|poisoned| poisoned.into_inner())
20085 .is_empty());
20086 assert!(shared
20087 .materializations
20088 .lock()
20089 .unwrap_or_else(|poisoned| poisoned.into_inner())
20090 .is_empty());
20091 }
20092
20093 #[tokio::test]
20094 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20095 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20096 let error = characterization_spawn(
20097 &shared,
20098 SessionMode::Pty,
20099 None,
20100 &[ProviderRuntimeCapability::SemanticReadiness],
20101 None,
20102 )
20103 .await
20104 .unwrap_err();
20105 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20106 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20107 assert_no_session_state(&shared);
20108 drop(shared);
20109 drop(runtime);
20110 std::fs::remove_dir_all(root).unwrap();
20111 }
20112
20113 #[tokio::test]
20114 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20115 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20116 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20117 .await
20118 .unwrap_err();
20119 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20120 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20121 assert_no_session_state(&shared);
20122 drop(shared);
20123 drop(runtime);
20124 std::fs::remove_dir_all(root).unwrap();
20125 }
20126
20127 #[test]
20128 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20129 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20130 let address = SessionAddress {
20131 workspace_id: WorkspaceId::new("primary").unwrap(),
20132 session: SessionKey {
20133 instance_id: AgentInstanceId(77),
20134 generation: SessionGeneration(1),
20135 },
20136 };
20137 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20138 let removed = shared.remove_binding(&address).expect("binding removed");
20139 assert!(removed.environment_profile.is_some());
20140 assert!(shared.remove_binding(&address).is_none());
20141 assert_no_session_state(&shared);
20142 drop(shared);
20143 drop(runtime);
20144 std::fs::remove_dir_all(root).unwrap();
20145 }
20146
20147 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20148 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20149 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20150 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20151 .expect("review current test executable as fixture helper");
20152 let registry = HarnessMcpProxyRegistry::new(helper);
20153 let spec = crate::protocol::SpawnSpec {
20154 target: crate::protocol::SpawnTarget {
20155 node_id: NodeId::new("node-characterization").unwrap(),
20156 workspace_id: WorkspaceId::new("primary").unwrap(),
20157 worktree_id: None,
20158 },
20159 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20160 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20161 overrides: crate::protocol::SpawnOverrides::default(),
20162 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20163 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20164 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20165 };
20166 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20167 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20168 let launch = HarnessMcpLaunchV1 {
20169 server_name: "fixture-harness".to_owned(),
20170 args: vec!["--session-proxy".to_owned()],
20171 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20172 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20173 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20174 trace: None,
20175 scrub_env: Vec::new(),
20176 };
20177 let expires = unix_time_ms().saturating_add(30_000);
20178 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20179 registry
20180 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20181 .unwrap()
20182 }
20183
20184 #[tokio::test]
20185 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20186 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20187 let prepared = characterization_prepared_harness("claude").await;
20188 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20189 .await
20190 .unwrap_err();
20191 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20193 let mismatched = characterization_prepared_harness("codex").await;
20194 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20195 .await
20196 .unwrap_err();
20197 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20198 assert_no_session_state(&shared);
20199 drop(shared);
20200 drop(runtime);
20201 std::fs::remove_dir_all(root).unwrap();
20202 }
20203
20204 #[tokio::test]
20205 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20206 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20207 shared.native_launch_profile_control = None;
20208 let prepared = characterization_prepared_harness("claude").await;
20209 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20210 .await
20211 .unwrap_err();
20212 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20213 assert_no_session_state(&shared);
20214 drop(shared);
20215 drop(runtime);
20216 std::fs::remove_dir_all(root).unwrap();
20217 }
20218}
20219
20220#[cfg(all(test, windows))]
20221mod tests {
20222 use super::*;
20223 use gate4agent_types::ProviderActivity;
20224 use crate::session_environment::{
20225 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20226 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20227 NodeSessionPathClass,
20228 };
20229 use gate4agent_catalog::EnvMutation;
20230 use gate4agent_runtime_native::{
20231 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20232 NativeLaunchProfile, NativeLaunchProfileId,
20233 };
20234 use std::ffi::OsString;
20235 use std::sync::Arc;
20236
20237 struct EmptyEnvironmentResolver;
20238
20239 struct FixtureSecretResolver {
20240 deny: Arc<AtomicBool>,
20241 }
20242
20243 impl NodeSecretResolver for FixtureSecretResolver {
20244 fn resolve(
20245 &self,
20246 _reference: &NodeSecretReference,
20247 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20248 if self.deny.load(Ordering::Acquire) {
20249 Err(NodeSecretResolveError::Denied)
20250 } else {
20251 NodeSecretValue::text("fixture-secret")
20252 .map_err(|_| NodeSecretResolveError::Unavailable)
20253 }
20254 }
20255 }
20256
20257 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20258 fn resolve_child_environment(
20259 &self,
20260 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20261 Ok(vec![EnvMutation {
20262 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20263 value: None,
20264 }])
20265 }
20266 }
20267
20268 fn agent(value: &str) -> AgentId {
20269 AgentId::new(value).unwrap()
20270 }
20271
20272 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20273 let catalog = active_registry().unwrap();
20274 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20275 let workspace = WorkspaceConfig::new(
20276 WorkspaceId::new("primary").unwrap(),
20277 std::env::current_dir().unwrap(),
20278 )
20279 .unwrap()
20280 .with_worktree_service_mode(mode);
20281 NodeShared::new(
20282 handle,
20283 "fixture-token".to_owned(),
20284 NodeId::new("node-terminal-test").unwrap(),
20285 vec![workspace],
20286 vec![agent("claude")],
20287 )
20288 }
20289
20290 fn terminal_test_shared() -> NodeShared {
20291 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20292 }
20293
20294 #[cfg(feature = "fixture")]
20295 #[test]
20296 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20297 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20298 assert_eq!(probe.latest_code(), None);
20299
20300 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20301 assert_eq!(
20302 probe.latest_code(),
20303 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20304 );
20305
20306 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20307 assert_eq!(
20308 probe.latest_code(),
20309 Some(NodeFailureCode::SpawnDeadlineExceeded),
20310 );
20311
20312 probe.record(None);
20313 assert_eq!(probe.latest_code(), None);
20314 }
20315
20316 #[test]
20317 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20318 let source = temporary_workspace_root("launch-inventory-source");
20319 let allocation = temporary_workspace_root("launch-inventory-allocation");
20320 std::fs::create_dir_all(&source).unwrap();
20321 std::fs::create_dir_all(&allocation).unwrap();
20322 let workspace_id = WorkspaceId::new("primary").unwrap();
20323 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20324 .unwrap()
20325 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20326 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20327 workspace = workspace
20328 .with_managed_worktree_profile(
20329 ManagedWorktreeProfile::new(
20330 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20331 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20332 &allocation,
20333 "gate4agent",
20334 "HEAD",
20335 ManagedWorktreeRetention::RemoveWhenReleased,
20336 )
20337 .unwrap(),
20338 )
20339 .unwrap();
20340 }
20341 let overflow = workspace.clone().with_managed_worktree_profile(
20342 ManagedWorktreeProfile::new(
20343 WorktreeProfileId::new("overflow").unwrap(),
20344 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20345 &allocation,
20346 "gate4agent",
20347 "HEAD",
20348 ManagedWorktreeRetention::Retain,
20349 )
20350 .unwrap(),
20351 );
20352 assert!(matches!(
20353 overflow,
20354 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20355 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20356 ..
20357 }),
20358 ));
20359
20360 let catalog = active_registry().unwrap();
20361 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20362 let shared = NodeShared::new(
20363 handle,
20364 "fixture-token".to_owned(),
20365 NodeId::new("node-launch-inventory").unwrap(),
20366 vec![workspace],
20367 vec![agent("claude")],
20368 );
20369 let snapshot = shared.snapshot();
20370 let launch = snapshot.launch_inventory.as_ref().unwrap();
20371 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20372 assert!(launch.bundles.as_ref().unwrap().is_empty());
20373 let profiles = snapshot.workspaces[0]
20374 .managed_worktree_profiles
20375 .as_ref()
20376 .unwrap();
20377 assert_eq!(
20378 snapshot.workspaces[0].worktree_service_mode,
20379 Some(WorktreeServiceMode::Managed),
20380 );
20381 assert_eq!(
20382 profiles.profiles.len(),
20383 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20384 );
20385 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20386 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20387 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20388 }
20389 let legacy = snapshot_for_wire(
20390 &shared,
20391 true,
20392 true,
20393 false,
20394 false,
20395 true,
20396 false,
20397 true,
20398 false,
20399 true,
20400 );
20401 assert!(legacy.launch_inventory.is_none());
20402 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20403
20404 drop(shared);
20405 std::fs::remove_dir_all(source).unwrap();
20406 std::fs::remove_dir_all(allocation).unwrap();
20407 }
20408
20409 #[test]
20410 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20411 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20412 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20413 }));
20414 let source = gate4agent_types::ProviderSource {
20415 family: AdapterFamily::PtySemantic,
20416 binding: AdapterBinding::new(
20417 gate4agent_types::AdapterId::new("claude").unwrap(),
20418 "fixture/v1",
20419 gate4agent_types::AdapterVerification::SyntheticFixture,
20420 )
20421 .unwrap(),
20422 };
20423 let provider = ProviderSnapshot {
20424 sequence: 17,
20425 session: Some(ProviderSessionIdentity {
20426 key: ProviderSessionKey::SessionId,
20427 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20428 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20429 }),
20430 model: Some("PRIVATE_MODEL".to_owned()),
20431 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20432 completed_turns: 3,
20433 usage: gate4agent_types::TokenUsage {
20434 input_tokens: 10,
20435 output_tokens: 20,
20436 cache_read_tokens: 30,
20437 cache_write_tokens: 40,
20438 reasoning_tokens: 50,
20439 context_window: Some(200_000),
20440 },
20441 lead_activity: ProviderActivity::WaitingForInput,
20442 activity: ProviderActivity::WaitingForInput,
20443 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20444 active_tools: (0..9)
20445 .map(|index| gate4agent_types::ActiveProviderTool {
20446 id: format!("private-tool-id-{index}"),
20447 name: if index == 0 {
20448 format!(" Read\u{0000}{}", "x".repeat(80))
20449 } else {
20450 format!("Tool-{index}")
20451 },
20452 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20453 })
20454 .collect(),
20455 interactions: vec![gate4agent_types::ProviderInteraction {
20456 id: gate4agent_types::ProviderInteractionId(17),
20457 source: source.clone(),
20458 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20459 interaction_kind: ProviderInteractionKind::Approval,
20460 tool_name: " Approve\u{0007}Tool ".to_owned(),
20461 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20462 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20463 resume_lead_activity: Some(ProviderActivity::Working),
20464 status: ProviderInteractionStatus::Pending,
20465 }],
20466 subagents: vec![gate4agent_types::ProviderSubagent {
20467 source: source.clone(),
20468 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20469 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20470 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20471 }],
20472 sources: vec![gate4agent_types::ProviderSourceCursor {
20473 source,
20474 sequence: 17,
20475 gap_count: 2,
20476 stale: true,
20477 }],
20478 last_event: Some(ProviderEvent::Error {
20479 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20480 }),
20481 gap_count: 2,
20482 stale: true,
20483 };
20484 let address = terminal_address(3);
20485 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20486 assert_eq!(entry.address, address);
20487 assert_eq!(entry.progress.provider_sequence, 17);
20488 assert_eq!(entry.progress.active_tool_count, 9);
20489 assert_eq!(
20490 entry.progress.active_tool_labels.len(),
20491 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20492 );
20493 assert_eq!(entry.progress.subagent_count, 1);
20494 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20495 assert!(entry.progress.truncated);
20496 let encoded = serde_json::to_string(&entry).unwrap();
20497 for forbidden in [
20498 "PRIVATE_PROVIDER_SESSION",
20499 "PRIVATE_PROVIDER_PATH",
20500 "PRIVATE_MODEL",
20501 "PRIVATE_TOOL_INVENTORY",
20502 "PRIVATE_CURRENT_PROMPT",
20503 "PRIVATE_TOOL_INPUT_JSON",
20504 "PRIVATE_PROVIDER_REQUEST",
20505 "PRIVATE_INTERACTION_PROMPT",
20506 "PRIVATE_INTERACTION_AGENT",
20507 "PRIVATE_SUBAGENT_ID",
20508 "PRIVATE_SUBAGENT_TYPE",
20509 "PRIVATE_SUBAGENT_DESCRIPTION",
20510 "PRIVATE_PROVIDER_ERROR",
20511 "input_json",
20512 "current_prompt",
20513 "session_id",
20514 ] {
20515 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20516 }
20517
20518 let snapshot = NodeSnapshot {
20519 node_id: NodeId::new("node-progress-test").unwrap(),
20520 enabled_providers: vec![agent("claude")],
20521 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20522 workspaces: Vec::new(),
20523 session_records: Vec::new(),
20524 managed_worktrees: Vec::new(),
20525 launch_inventory: None,
20526 agent_progress: vec![entry],
20527 };
20528 let mut legacy_reply = ResponseEnvelope {
20529 request_id: 1,
20530 result: Ok(NodeResponse::Snapshot {
20531 event_sequence: 0,
20532 controller: None,
20533 snapshot: snapshot.clone(),
20534 }),
20535 };
20536 project_response_without_agent_progress(&mut legacy_reply);
20537 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20538 panic!("agent progress projection changed response kind");
20539 };
20540 assert!(legacy.agent_progress.is_empty());
20541 assert_eq!(snapshot.agent_progress.len(), 1);
20542 }
20543
20544 fn terminal_address(generation: u64) -> SessionAddress {
20545 SessionAddress {
20546 workspace_id: WorkspaceId::new("primary").unwrap(),
20547 session: SessionKey {
20548 instance_id: AgentInstanceId(41),
20549 generation: SessionGeneration(generation),
20550 },
20551 }
20552 }
20553
20554 fn spawn_spec_fixture() -> SpawnSpec {
20555 SpawnSpec {
20556 target: crate::protocol::SpawnTarget {
20557 node_id: NodeId::new("node-terminal-test").unwrap(),
20558 workspace_id: WorkspaceId::new("primary").unwrap(),
20559 worktree_id: None,
20560 },
20561 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20562 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20563 .unwrap(),
20564 overrides: crate::protocol::SpawnOverrides::default(),
20565 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20566 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20567 required_capabilities: SpawnRequiredCapabilities::default(),
20568 }
20569 }
20570
20571 fn environment_profile_test_shared(
20572 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20573 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20574 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20575 "local-claude-r1",
20576 )
20577 .unwrap();
20578 let spawn_profiles = SpawnProfileRegistry::new([
20579 crate::protocol::SpawnProfileDefaults {
20580 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20581 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20582 provider: agent("claude"),
20583 mode: SessionMode::Pty,
20584 terminal_size: gate4agent_types::TerminalSize {
20585 rows: 24,
20586 columns: 80,
20587 },
20588 prompt: None,
20589 bundle_id: None,
20590 context_id: None,
20591 environment_profile_id: Some(profile_id.clone()),
20592 },
20593 ])
20594 .unwrap();
20595 let catalog = active_registry().unwrap();
20596 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20597 let control = runtime.native_launch_profile_control();
20598 let node_profile = NodeEnvironmentProfile::new(
20599 profile_id.clone(),
20600 profile_revision.clone(),
20601 agent("claude"),
20602 [NativeLaunchProfile::new(
20603 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20604 agent("claude"),
20605 TransportKind::Pty,
20606 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20607 Arc::new(EmptyEnvironmentResolver),
20608 )
20609 .unwrap()],
20610 )
20611 .unwrap();
20612 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20613 for native_profile in native_profiles {
20614 runtime.upsert_native_launch_profile(native_profile).unwrap();
20615 }
20616 let workspace = WorkspaceConfig::new(
20617 WorkspaceId::new("primary").unwrap(),
20618 std::env::current_dir().unwrap(),
20619 )
20620 .unwrap();
20621 let shared = NodeShared::new_with_incarnation(
20622 handle,
20623 "fixture-token".to_owned(),
20624 NodeId::new("node-terminal-test").unwrap(),
20625 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20626 vec![workspace],
20627 vec![agent("claude")],
20628 ProviderRuntimeStatuses::default(),
20629 None,
20630 Vec::new(),
20631 Vec::new(),
20632 spawn_profiles,
20633 Some(control),
20634 None,
20635 None,
20636 None,
20637 Vec::new(),
20638 Vec::new(),
20639 Vec::new(),
20640 Vec::new(),
20641 None,
20642 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20643 );
20644 shared
20645 .environment_profiles
20646 .write()
20647 .unwrap_or_else(|poisoned| poisoned.into_inner())
20648 .insert(binding.id.clone(), binding);
20649 (
20650 shared,
20651 runtime,
20652 ResolvedEnvironmentProfileReceipt {
20653 profile_id,
20654 profile_revision,
20655 network_allowlist: None,
20656 browser_profile_id: None,
20657 },
20658 )
20659 }
20660
20661 fn materialization_test_shared(
20662 deny_secret: bool,
20663 ) -> (
20664 NodeShared,
20665 NativeRuntime,
20666 ResolvedEnvironmentProfileReceipt,
20667 PathBuf,
20668 Arc<AtomicBool>,
20669 ) {
20670 let workspace = WorkspaceConfig::new(
20671 WorkspaceId::new("primary").unwrap(),
20672 std::env::current_dir().unwrap(),
20673 )
20674 .unwrap();
20675 materialization_test_shared_with_workspace(deny_secret, workspace)
20676 }
20677
20678 fn materialization_test_shared_with_workspace(
20679 deny_secret: bool,
20680 workspace: WorkspaceConfig,
20681 ) -> (
20682 NodeShared,
20683 NativeRuntime,
20684 ResolvedEnvironmentProfileReceipt,
20685 PathBuf,
20686 Arc<AtomicBool>,
20687 ) {
20688 let root = temporary_workspace_root(if deny_secret {
20689 "materialization-denied"
20690 } else {
20691 "materialization-ready"
20692 });
20693 std::fs::create_dir_all(&root).unwrap();
20694 let materialization_root = root.join("session-environments");
20695 let deny = Arc::new(AtomicBool::new(deny_secret));
20696 let resolver = Arc::new(FixtureSecretResolver {
20697 deny: Arc::clone(&deny),
20698 });
20699 let materializer = SessionEnvironmentMaterializer::new(
20700 materialization_root,
20701 resolver,
20702 )
20703 .unwrap();
20704 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20705 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20706 "materialized-claude-r1",
20707 )
20708 .unwrap();
20709 let materialization = NodeSessionMaterializationProfile::new(
20710 vec![NodeSessionEnvironmentMutation::SetSecret {
20711 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20712 reference: NodeSecretReference::new("fixture-token").unwrap(),
20713 }],
20714 vec![NodeSessionPathBinding::new(
20715 "GATE4AGENT_PROVIDER_HOME",
20716 NodeSessionPathClass::ProviderHome,
20717 )
20718 .unwrap()],
20719 vec![NodeSessionFile::secret(
20720 NodeSessionPathClass::Config,
20721 "auth/token",
20722 NodeSecretReference::new("fixture-token").unwrap(),
20723 )
20724 .unwrap()],
20725 )
20726 .unwrap();
20727 let catalog = active_registry().unwrap();
20728 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20729 let control = runtime.native_launch_profile_control();
20730 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20731 profile_id.clone(),
20732 profile_revision.clone(),
20733 agent("claude"),
20734 [NativeLaunchProfile::new(
20735 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20736 agent("claude"),
20737 TransportKind::Pty,
20738 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20739 Arc::new(EmptyEnvironmentResolver),
20740 )
20741 .unwrap()],
20742 Some(materialization),
20743 )
20744 .unwrap();
20745 let (binding, native_profiles, materialization) = node_profile.into_parts();
20746 for native_profile in native_profiles {
20747 runtime.upsert_native_launch_profile(native_profile).unwrap();
20748 }
20749 let shared = NodeShared::new_with_incarnation(
20750 handle,
20751 "fixture-token".to_owned(),
20752 NodeId::new("node-materialization-test").unwrap(),
20753 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20754 vec![workspace],
20755 vec![agent("claude")],
20756 ProviderRuntimeStatuses::default(),
20757 None,
20758 Vec::new(),
20759 Vec::new(),
20760 SpawnProfileRegistry::default(),
20761 Some(control),
20762 None,
20763 Some(materializer),
20764 None,
20765 Vec::new(),
20766 Vec::new(),
20767 Vec::new(),
20768 Vec::new(),
20769 None,
20770 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20771 );
20772 shared
20773 .environment_profiles
20774 .write()
20775 .unwrap_or_else(|poisoned| poisoned.into_inner())
20776 .insert(binding.id.clone(), binding);
20777 shared
20778 .environment_materialization_profiles
20779 .write()
20780 .unwrap_or_else(|poisoned| poisoned.into_inner())
20781 .insert(profile_id.clone(), materialization.unwrap());
20782 (
20783 shared,
20784 runtime,
20785 ResolvedEnvironmentProfileReceipt {
20786 profile_id,
20787 profile_revision,
20788 network_allowlist: None,
20789 browser_profile_id: None,
20790 },
20791 root,
20792 deny,
20793 )
20794 }
20795
20796 #[cfg(feature = "fixture")]
20797 #[derive(Clone, Copy)]
20798 enum CodexHomeProfile {
20799 Missing,
20800 WrongClass,
20801 Exact,
20802 }
20803
20804 #[cfg(feature = "fixture")]
20805 fn codex_bundle_test_shared(
20806 home_profile: CodexHomeProfile,
20807 ) -> (
20808 NodeShared,
20809 NativeRuntime,
20810 ResolvedEnvironmentProfileReceipt,
20811 NodeBundle,
20812 PathBuf,
20813 ) {
20814 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20815 const CLAUDE_MANIFEST: &[u8] =
20816 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20817 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20818 const DIGEST: &str =
20819 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20820
20821 let (shared, mut runtime, _claude_receipt, root, _deny) =
20822 materialization_test_shared(false);
20823 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20824 CodexHomeProfile::Missing => "codex-home-missing",
20825 CodexHomeProfile::WrongClass => "codex-home-wrong",
20826 CodexHomeProfile::Exact => "codex-home-exact",
20827 })
20828 .unwrap();
20829 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20830 "codex-home-r1",
20831 )
20832 .unwrap();
20833 let materialization = match home_profile {
20834 CodexHomeProfile::Missing => None,
20835 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20836 Some(NodeSessionMaterializationProfile::new(
20837 Vec::new(),
20838 vec![NodeSessionPathBinding::new(
20839 "CODEX_HOME",
20840 if matches!(home_profile, CodexHomeProfile::Exact) {
20841 NodeSessionPathClass::ProviderHome
20842 } else {
20843 NodeSessionPathClass::Config
20844 },
20845 )
20846 .unwrap()],
20847 Vec::new(),
20848 )
20849 .unwrap())
20850 }
20851 };
20852 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20853 profile_id.clone(),
20854 profile_revision.clone(),
20855 agent("codex"),
20856 [NativeLaunchProfile::new(
20857 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20858 agent("codex"),
20859 TransportKind::Pty,
20860 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20861 Arc::new(EmptyEnvironmentResolver),
20862 )
20863 .unwrap()],
20864 materialization,
20865 )
20866 .unwrap();
20867 let (binding, native_profiles, materialization) = node_profile.into_parts();
20868 for native_profile in native_profiles {
20869 runtime.upsert_native_launch_profile(native_profile).unwrap();
20870 }
20871 shared
20872 .environment_profiles
20873 .write()
20874 .unwrap_or_else(|poisoned| poisoned.into_inner())
20875 .insert(binding.id.clone(), binding);
20876 if let Some(materialization) = materialization {
20877 shared
20878 .environment_materialization_profiles
20879 .write()
20880 .unwrap_or_else(|poisoned| poisoned.into_inner())
20881 .insert(profile_id.clone(), materialization);
20882 }
20883
20884 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20885 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20886 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20887 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20888 std::fs::write(
20889 bundle_source.join(".claude-plugin/plugin.json"),
20890 CLAUDE_MANIFEST,
20891 )
20892 .unwrap();
20893 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20894 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20895 let bundle = NodeBundle::new(
20896 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20897 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20898 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20899 &bundle_source,
20900 )
20901 .unwrap();
20902 *shared
20903 .bundle_catalog
20904 .write()
20905 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20906 BundleCatalog::new([bundle.clone()]).unwrap();
20907 (
20908 shared,
20909 runtime,
20910 ResolvedEnvironmentProfileReceipt {
20911 profile_id,
20912 profile_revision,
20913 network_allowlist: None,
20914 browser_profile_id: None,
20915 },
20916 bundle,
20917 root,
20918 )
20919 }
20920
20921 #[cfg(feature = "fixture")]
20922 #[test]
20923 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20924 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20925 let (shared, runtime, receipt, bundle, root) =
20926 codex_bundle_test_shared(home_profile);
20927 let error = shared
20928 .bundle_layout(
20929 &agent("codex"),
20930 SessionMode::Pty,
20931 Some(&receipt),
20932 &bundle,
20933 )
20934 .unwrap_err();
20935 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20936 assert!(shared
20937 .materializations
20938 .lock()
20939 .unwrap_or_else(|poisoned| poisoned.into_inner())
20940 .is_empty());
20941 let materialization_root = root.join("session-environments");
20942 assert!(!std::fs::read_dir(&materialization_root)
20943 .unwrap()
20944 .filter_map(Result::ok)
20945 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20946 drop(shared);
20947 drop(runtime);
20948 let _ = std::fs::remove_dir_all(root);
20949 }
20950 }
20951
20952 #[cfg(feature = "fixture")]
20953 #[test]
20954 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20955 let (shared, runtime, receipt, bundle, root) =
20956 codex_bundle_test_shared(CodexHomeProfile::Exact);
20957 let address = SessionAddress {
20958 workspace_id: WorkspaceId::new("primary").unwrap(),
20959 session: SessionKey {
20960 instance_id: AgentInstanceId(310),
20961 generation: SessionGeneration(1),
20962 },
20963 };
20964 let (overlay, guard) = shared
20965 .prepare_session_materialization(
20966 &address,
20967 &agent("codex"),
20968 SessionMode::Pty,
20969 Some(&receipt),
20970 Some(&bundle.receipt()),
20971 None,
20972 None,
20973 )
20974 .unwrap()
20975 .unwrap();
20976 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20977 let ownership = shared
20978 .materializations
20979 .lock()
20980 .unwrap_or_else(|poisoned| poisoned.into_inner())
20981 .get(guard.id().unwrap())
20982 .unwrap()
20983 .clone();
20984 let (profile, _, _) = shared
20985 .materialization_profile(
20986 &agent("codex"),
20987 SessionMode::Pty,
20988 Some(&receipt),
20989 Some(&bundle.receipt()),
20990 None,
20991 )
20992 .unwrap()
20993 .unwrap();
20994 let environment = shared
20995 .session_environment_materializer
20996 .as_ref()
20997 .unwrap()
20998 .resolve_environment(&ownership, &profile)
20999 .unwrap();
21000 assert!(environment.iter().any(|mutation| {
21001 mutation.key == OsString::from("CODEX_HOME")
21002 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21003 }));
21004 assert_eq!(
21005 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21006 .unwrap(),
21007 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21008 );
21009 assert!(std::fs::read_dir(ownership.bundle_root())
21010 .unwrap()
21011 .next()
21012 .is_none());
21013 drop(guard);
21014 drop(shared);
21015 drop(runtime);
21016 let _ = std::fs::remove_dir_all(root);
21017 }
21018
21019 #[cfg(feature = "fixture")]
21020 #[test]
21021 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21022 let (shared, runtime, receipt, bundle, root) =
21023 codex_bundle_test_shared(CodexHomeProfile::Exact);
21024 let bundle_receipt = bundle.receipt();
21025 let address = SessionAddress {
21026 workspace_id: WorkspaceId::new("primary").unwrap(),
21027 session: SessionKey {
21028 instance_id: AgentInstanceId(311),
21029 generation: SessionGeneration(1),
21030 },
21031 };
21032 let (_overlay, guard) = shared
21033 .prepare_session_materialization(
21034 &address,
21035 &agent("codex"),
21036 SessionMode::Pty,
21037 Some(&receipt),
21038 Some(&bundle_receipt),
21039 None,
21040 None,
21041 )
21042 .unwrap()
21043 .unwrap();
21044 let materialization_id = guard.id().unwrap().clone();
21045 let skill_path = shared
21046 .materializations
21047 .lock()
21048 .unwrap_or_else(|poisoned| poisoned.into_inner())
21049 .get(&materialization_id)
21050 .unwrap()
21051 .provider_home()
21052 .join("skills/review-code/SKILL.md");
21053 let record_id = shared
21054 .bind_spawn_session_with_materialization(
21055 &address,
21056 agent("codex"),
21057 SessionMode::Pty,
21058 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21059 SpawnRecordPolicy::ProviderIdentityOnly,
21060 Some(receipt.clone()),
21061 Some(bundle_receipt.clone()),
21062 None,
21063 Some(materialization_id.clone()),
21064 )
21065 .unwrap()
21066 .unwrap();
21067 guard.retain();
21068 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21069
21070 let error = match shared.resolve_record_materialization(
21071 &record_id,
21072 &agent("codex"),
21073 SessionMode::Pty,
21074 Some(&receipt),
21075 Some(&bundle_receipt),
21076 None,
21077 ) {
21078 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21079 Err(error) => error,
21080 };
21081 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21082 assert_eq!(
21083 shared
21084 .materializations
21085 .lock()
21086 .unwrap_or_else(|poisoned| poisoned.into_inner())
21087 .get(&materialization_id)
21088 .unwrap()
21089 .state(),
21090 MaterializationState::RecoveryRequired,
21091 );
21092 drop(shared);
21093 drop(runtime);
21094 let _ = std::fs::remove_dir_all(root);
21095 }
21096
21097 fn bind_record_owned_materialization_fixture(
21098 shared: &NodeShared,
21099 receipt: &ResolvedEnvironmentProfileReceipt,
21100 instance_id: u64,
21101 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21102 let address = SessionAddress {
21103 workspace_id: WorkspaceId::new("primary").unwrap(),
21104 session: SessionKey {
21105 instance_id: AgentInstanceId(instance_id),
21106 generation: SessionGeneration(1),
21107 },
21108 };
21109 let (overlay, materialization_guard) = shared
21110 .prepare_session_materialization(
21111 &address,
21112 &agent("claude"),
21113 SessionMode::Pty,
21114 Some(receipt),
21115 None,
21116 None,
21117 None,
21118 )
21119 .unwrap()
21120 .unwrap();
21121 let materialization_id = materialization_guard.id().unwrap().clone();
21122 let materialization_root = shared
21123 .materializations
21124 .lock()
21125 .unwrap_or_else(|poisoned| poisoned.into_inner())
21126 .get(&materialization_id)
21127 .unwrap()
21128 .root()
21129 .to_path_buf();
21130 let selection = shared
21131 .select_environment_profile(
21132 address.session.instance_id,
21133 &agent("claude"),
21134 SessionMode::Pty,
21135 Some(receipt),
21136 )
21137 .unwrap()
21138 .unwrap();
21139 assert!(shared
21140 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21141 .unwrap()
21142 .is_none());
21143 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21144 let record_id = shared
21145 .bind_spawn_session_with_materialization(
21146 &address,
21147 agent("claude"),
21148 SessionMode::Pty,
21149 policy,
21150 SpawnRecordPolicy::ProviderIdentityOnly,
21151 Some(receipt.clone()),
21152 None,
21153 None,
21154 Some(materialization_id.clone()),
21155 )
21156 .unwrap()
21157 .unwrap();
21158 selection.retain();
21159 materialization_guard.retain();
21160 (address, record_id, materialization_id, materialization_root)
21161 }
21162
21163 #[test]
21164 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21165 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21166 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21167 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21168 }));
21169 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21170 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21171 let mut spec = spawn_spec_fixture();
21172 spec.expected_profile_revision =
21173 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21174 assert!(matches!(
21175 &spec.overrides.environment_profile_id,
21176 crate::protocol::SpawnOverride::Inherit,
21177 ));
21178 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21179 .requires_child_environment_profile_capability());
21180 assert!(request_requires_child_environment_profile(
21181 &shared,
21182 &NodeRequest::SpawnSpec { spec: spec.clone() },
21183 ));
21184
21185 let address = terminal_address(1);
21186 shared.bind_session_with_policy(
21187 &address,
21188 ProviderRuntimePolicy::raw_pty(),
21189 Some(environment_profile.clone()),
21190 );
21191 assert!(request_requires_child_environment_profile(
21192 &shared,
21193 &NodeRequest::Input {
21194 session: address.clone(),
21195 text: "x".to_owned(),
21196 },
21197 ));
21198
21199 let mut record = record("claude", "profile-record");
21200 record.active_session = Some(address.clone());
21201 record.environment_profile = Some(environment_profile.clone());
21202 let record_id = record.record_id.clone();
21203 shared.insert_record(record.clone()).unwrap();
21204 assert!(request_requires_child_environment_profile(
21205 &shared,
21206 &NodeRequest::ResumeSessionRecord {
21207 record_id,
21208 terminal_size: gate4agent_types::TerminalSize {
21209 rows: 24,
21210 columns: 80,
21211 },
21212 initial_prompt: None,
21213 },
21214 ));
21215
21216 let snapshot = snapshot_for_wire(
21217 &shared, false, true, true, true, false, false, false, false, true,
21218 );
21219 assert_eq!(snapshot.session_records.len(), 1);
21220 assert!(snapshot.session_records[0].environment_profile.is_none());
21221 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21222 sequence: 1,
21223 event: NodeEvent::SessionRecordUpserted { record },
21224 });
21225 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21226 panic!("record projection changed the event kind");
21227 };
21228 assert!(record.environment_profile.is_none());
21229
21230 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21231 let mut reply = ResponseEnvelope {
21232 request_id: 1,
21233 result: Ok(NodeResponse::SpawnSpecAccepted {
21234 receipt: resolved.receipt_with_environment(
21235 shared.incarnation_id,
21236 address,
21237 Some(environment_profile),
21238 ),
21239 }),
21240 };
21241 project_response_without_child_environment_profile(&mut reply);
21242 assert_eq!(
21243 reply.result.unwrap_err().code,
21244 NodeFailureCode::UnsupportedCapability,
21245 );
21246 }
21247
21248 #[test]
21249 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21250 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21251 let snapshot = shared.snapshot();
21252 let profiles = snapshot
21253 .launch_inventory
21254 .as_ref()
21255 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21256 .unwrap();
21257 assert_eq!(profiles.len(), 1);
21258 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21259 assert!(snapshot.requires_child_environment_profile_capability());
21260
21261 let mut legacy = snapshot.clone();
21262 clear_snapshot_child_environment_profiles(&mut legacy);
21263 assert!(legacy
21264 .launch_inventory
21265 .as_ref()
21266 .unwrap()
21267 .spawn_profiles
21268 .as_ref()
21269 .unwrap()[0]
21270 .environment_profile
21271 .is_none());
21272 assert!(!legacy.requires_child_environment_profile_capability());
21273
21274 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21275 let bindings = shared
21276 .environment_profiles
21277 .read()
21278 .unwrap_or_else(|poisoned| poisoned.into_inner())
21279 .clone();
21280 let mut provider_mismatch = bindings.clone();
21281 provider_mismatch
21282 .values_mut()
21283 .next()
21284 .unwrap()
21285 .provider = agent("codex");
21286 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21287 let mut mode_mismatch = profile;
21288 mode_mismatch.mode = SessionMode::Inline;
21289 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21290
21291 shared
21292 .environment_profiles
21293 .write()
21294 .unwrap_or_else(|poisoned| poisoned.into_inner())
21295 .clear();
21296 assert!(shared
21297 .snapshot()
21298 .launch_inventory
21299 .unwrap()
21300 .spawn_profiles
21301 .unwrap()
21302 .is_empty());
21303 }
21304
21305 #[test]
21306 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21307 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21308 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21309 }));
21310 let shared = terminal_test_shared();
21311 let bundle = ResolvedBundleReceipt {
21312 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21313 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21314 digest: crate::protocol::SpawnBundleDigest::new(format!(
21315 "sha256:{}",
21316 "0".repeat(64),
21317 ))
21318 .unwrap(),
21319 };
21320 let mut bundled = record("claude", "bundle-record");
21321 bundled.bundle = Some(bundle.clone());
21322 let record_id = bundled.record_id.clone();
21323 shared.insert_record(bundled.clone()).unwrap();
21324 assert!(request_requires_session_bundle(
21325 &shared,
21326 &NodeRequest::ForgetSessionRecord {
21327 record_id: record_id.clone(),
21328 },
21329 ));
21330
21331 let snapshot = snapshot_for_wire(
21332 &shared, false, true, true, true, true, false, false, false, true,
21333 );
21334 assert!(snapshot.session_records[0].bundle.is_none());
21335 let launch = snapshot.launch_inventory.as_ref().unwrap();
21336 assert!(launch.spawn_profiles.is_some());
21337 assert!(launch.bundles.is_none());
21338 let event = project_event_without_session_bundle(NodeEventEnvelope {
21339 sequence: 1,
21340 event: NodeEvent::SessionRecordUpserted { record: bundled },
21341 });
21342 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21343 panic!("record projection changed the event kind");
21344 };
21345 assert!(record.bundle.is_none());
21346
21347 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21348 let mut reply = ResponseEnvelope {
21349 request_id: 1,
21350 result: Ok(NodeResponse::SpawnSpecAccepted {
21351 receipt: resolved.receipt_with_materialization(
21352 shared.incarnation_id,
21353 terminal_address(17),
21354 None,
21355 Some(bundle),
21356 None,
21357 ),
21358 }),
21359 };
21360 project_response_without_session_bundle(&mut reply);
21361 assert_eq!(
21362 reply.result.unwrap_err().code,
21363 NodeFailureCode::UnsupportedCapability,
21364 );
21365 }
21366
21367 #[test]
21368 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21369 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21370 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21371 }));
21372 let context_id = SpawnContextId::new("context-review").unwrap();
21373 let context = ResolvedContextPackReceipt {
21374 id: context_id.clone(),
21375 digest: crate::protocol::SpawnContextDigest::new(format!(
21376 "sha256:{}",
21377 "c".repeat(64),
21378 ))
21379 .unwrap(),
21380 lineage: ContextPackLineageReceipt {
21381 source_node_id: NodeId::new("node-source").unwrap(),
21382 source_session: terminal_address(1),
21383 source_provider: agent("kimi"),
21384 },
21385 source_message_count: 2,
21386 retained_message_count: 2,
21387 byte_len: 256,
21388 truncated: false,
21389 };
21390 assert!(context.is_valid());
21391 let mut bound_record = record("codex", "context-record");
21392 bound_record.context_id = Some(context_id);
21393 bound_record.context = Some(context.clone());
21394 let session = gate4agent_types::SessionSnapshot {
21395 instance_id: AgentInstanceId(91),
21396 agent_id: agent("kimi"),
21397 transport: TransportKind::Pty,
21398 generation: SessionGeneration(3),
21399 status: SessionStatus::Running,
21400 pending_operation: None,
21401 pending_input: None,
21402 process_id: Some(9001),
21403 terminal_size: None,
21404 terminal_frame: None,
21405 terminal_stale: None,
21406 session_options: None,
21407 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21408 history: gate4agent_types::HistorySnapshot {
21409 pending: None,
21410 candidates: vec![HistoryCandidateSummary {
21411 id: "candidate-opaque".to_owned(),
21412 session_id_hint: "session-hint".to_owned(),
21413 modified_at_unix_ms: Some(1),
21414 }],
21415 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21416 loaded: Some(HistorySessionRecord {
21417 session_id: "private-vendor-session".to_owned(),
21418 title: Some("private title".to_owned()),
21419 cwd: Some(r"C:\private\history-root".to_owned()),
21420 model: Some("private-model".to_owned()),
21421 message_count: 2,
21422 completed_turn_count: None,
21423 total_tokens: 19,
21424 messages: vec![
21425 gate4agent_types::HistoryMessageRecord {
21426 role: gate4agent_types::HistoryMessageRole::User,
21427 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21428 },
21429 gate4agent_types::HistoryMessageRecord {
21430 role: gate4agent_types::HistoryMessageRole::Assistant,
21431 text: "private answer".to_owned(),
21432 },
21433 ],
21434 }),
21435 last_error: None,
21436 },
21437 resume: gate4agent_types::ResumeSnapshot::default(),
21438 foreground: gate4agent_types::ForegroundSnapshot::default(),
21439 provider: gate4agent_types::ProviderSnapshot::default(),
21440 screen_state: gate4agent_types::PtyScreenState::default(),
21441 };
21442 let original = NodeSnapshot {
21443 node_id: NodeId::new("node-terminal-test").unwrap(),
21444 enabled_providers: vec![agent("kimi"), agent("codex")],
21445 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21446 workspaces: vec![WorkspaceSnapshot {
21447 workspace_id: WorkspaceId::new("primary").unwrap(),
21448 canonical_root: opaque_windows_path(
21449 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21450 ),
21451 sessions: vec![session],
21452 worktree_service_mode: None,
21453 managed_worktree_profiles: None,
21454 }],
21455 session_records: vec![bound_record],
21456 managed_worktrees: Vec::new(),
21457 launch_inventory: None,
21458 agent_progress: Vec::new(),
21459 };
21460
21461 let mut negotiated = original.clone();
21462 project_snapshot_history_for_wire(&mut negotiated, true);
21463 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21464 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21465 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21466 assert!(!serde_json::to_string(&negotiated)
21467 .unwrap()
21468 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21469
21470 let mut legacy = original;
21471 project_snapshot_history_for_wire(&mut legacy, false);
21472 clear_snapshot_context_packs(&mut legacy);
21473 assert_eq!(
21474 legacy.workspaces[0].sessions[0].history,
21475 gate4agent_types::HistorySnapshot::default(),
21476 );
21477 assert!(legacy.session_records[0].context_id.is_none());
21478 assert!(legacy.session_records[0].context.is_none());
21479 let encoded = serde_json::to_string(&legacy).unwrap();
21480 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21481 assert!(!encoded.contains("context-review"));
21482
21483 let mut reply = ResponseEnvelope {
21484 request_id: 7,
21485 result: Ok(NodeResponse::ContextPackExported { context }),
21486 };
21487 project_response_without_context_pack(&mut reply);
21488 assert_eq!(
21489 reply.result.unwrap_err().code,
21490 NodeFailureCode::UnsupportedCapability,
21491 );
21492 }
21493
21494 #[test]
21495 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21496 let shared = terminal_test_shared();
21497 let history = HistorySessionRecord {
21498 session_id: "fixture-history".to_owned(),
21499 title: None,
21500 cwd: None,
21501 model: None,
21502 message_count: 1,
21503 completed_turn_count: None,
21504 total_tokens: 0,
21505 messages: vec![gate4agent_types::HistoryMessageRecord {
21506 role: gate4agent_types::HistoryMessageRole::User,
21507 text: "bounded handoff".to_owned(),
21508 }],
21509 };
21510 let pack = NodeContextPack::export(
21511 ContextPackLineageReceipt {
21512 source_node_id: shared.node_id.clone(),
21513 source_session: terminal_address(1),
21514 source_provider: agent("claude"),
21515 },
21516 &history,
21517 )
21518 .unwrap();
21519 let context = shared
21520 .context_catalog
21521 .write()
21522 .unwrap_or_else(|poisoned| poisoned.into_inner())
21523 .insert(pack)
21524 .unwrap();
21525 let mut retained = record("codex", "context-retained-record");
21526 retained.context_id = Some(context.id.clone());
21527 retained.context = Some(context.clone());
21528 let record_id = retained.record_id.clone();
21529 shared.insert_record(retained).unwrap();
21530 assert_eq!(
21531 shared.forget_context_pack(&context.id).unwrap_err().code,
21532 NodeFailureCode::ContextPackBusy,
21533 );
21534 assert!(shared
21535 .context_catalog
21536 .read()
21537 .unwrap_or_else(|poisoned| poisoned.into_inner())
21538 .get(&context.id)
21539 .is_some());
21540 shared
21541 .session_records
21542 .lock()
21543 .unwrap_or_else(|poisoned| poisoned.into_inner())
21544 .records
21545 .remove(&record_id);
21546
21547 let mut spec = spawn_spec_fixture();
21548 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21549 value: context.id.clone(),
21550 };
21551 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21552 let receipt = resolved.receipt_with_materialization(
21553 shared.incarnation_id,
21554 terminal_address(2),
21555 None,
21556 None,
21557 Some(context.clone()),
21558 );
21559 assert!(receipt.context_binding_is_valid());
21560 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21561
21562 shared.forget_context_pack(&context.id).unwrap();
21563 assert!(shared
21564 .context_catalog
21565 .read()
21566 .unwrap_or_else(|poisoned| poisoned.into_inner())
21567 .get(&context.id)
21568 .is_none());
21569 assert!(shared
21570 .replay_spawn_spec(&spec, Instant::now())
21571 .unwrap()
21572 .is_none());
21573 }
21574
21575 #[test]
21576 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21577 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21578 let address = terminal_address(1);
21579 let selection = shared
21580 .select_environment_profile(
21581 address.session.instance_id,
21582 &agent("claude"),
21583 SessionMode::Pty,
21584 Some(&environment_profile),
21585 )
21586 .unwrap()
21587 .unwrap();
21588 shared.bind_session_with_policy(
21589 &address,
21590 ProviderRuntimePolicy::raw_pty(),
21591 Some(environment_profile.clone()),
21592 );
21593 selection.retain();
21594 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21595 assert!(matches!(
21596 runtime.remove_native_launch_profile(&native_id),
21597 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21598 ));
21599 assert!(shared.remove_binding(&address).is_some());
21600 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21601
21602 let mismatched = ResolvedEnvironmentProfileReceipt {
21603 profile_id: environment_profile.profile_id,
21604 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21605 "local-claude-r2",
21606 )
21607 .unwrap(),
21608 network_allowlist: None,
21609 browser_profile_id: None,
21610 };
21611 let error = match shared.select_environment_profile(
21612 AgentInstanceId(42),
21613 &agent("claude"),
21614 SessionMode::Pty,
21615 Some(&mismatched),
21616 ) {
21617 Ok(_) => panic!("mismatched environment profile revision was selected"),
21618 Err(error) => error,
21619 };
21620 assert_eq!(
21621 error.code,
21622 NodeFailureCode::EnvironmentProfileBindingMismatch,
21623 );
21624 assert!(!shared
21625 .session_bindings
21626 .lock()
21627 .unwrap_or_else(|poisoned| poisoned.into_inner())
21628 .contains_key(&AgentInstanceId(42)));
21629 }
21630
21631 #[test]
21632 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21633 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21634 let address = terminal_address(1);
21635 let selection = shared
21636 .select_environment_profile(
21637 address.session.instance_id,
21638 &agent("claude"),
21639 SessionMode::Pty,
21640 Some(&environment_profile),
21641 )
21642 .unwrap()
21643 .unwrap();
21644 let overlay = NativeInstanceLaunchOverlay::new(
21645 agent("claude"),
21646 TransportKind::Pty,
21647 vec![EnvMutation {
21648 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21649 value: Some(OsString::from("fixture-secret")),
21650 }],
21651 vec![
21652 OsString::from("--plugin-dir"),
21653 std::env::current_dir().unwrap().into_os_string(),
21654 ],
21655 )
21656 .unwrap();
21657 let instance_overlay = shared
21658 .install_prepared_launch_overlay(
21659 address.session.instance_id,
21660 PreparedNativeLaunchOverlay::Instance(overlay),
21661 )
21662 .unwrap()
21663 .unwrap();
21664 let bundle = ResolvedBundleReceipt {
21665 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21666 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21667 digest: crate::protocol::SpawnBundleDigest::new(format!(
21668 "sha256:{}",
21669 "0".repeat(64),
21670 ))
21671 .unwrap(),
21672 };
21673 shared.bind_session_with_materialization(
21674 &address,
21675 ProviderRuntimePolicy::raw_pty(),
21676 Some(environment_profile),
21677 Some(bundle),
21678 None,
21679 None,
21680 );
21681 selection.retain();
21682 instance_overlay.retain();
21683
21684 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21685 assert!(matches!(
21686 runtime.remove_native_launch_profile(&native_id),
21687 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21688 ));
21689 assert!(shared.remove_binding(&address).is_some());
21690 assert!(!shared
21691 .native_launch_profile_control
21692 .as_ref()
21693 .unwrap()
21694 .clear_native_instance_launch_overlay(address.session.instance_id));
21695 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21696 }
21697
21698 #[test]
21699 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21700 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21701 let address = SessionAddress {
21702 workspace_id: WorkspaceId::new("primary").unwrap(),
21703 session: SessionKey {
21704 instance_id: AgentInstanceId(201),
21705 generation: SessionGeneration(1),
21706 },
21707 };
21708 let error = match shared.prepare_session_materialization(
21709 &address,
21710 &agent("claude"),
21711 SessionMode::Pty,
21712 Some(&receipt),
21713 None,
21714 None,
21715 None,
21716 ) {
21717 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21718 Err(error) => error,
21719 };
21720 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21721 assert!(shared.handle.snapshot().sessions.is_empty());
21722 assert!(shared
21723 .session_bindings
21724 .lock()
21725 .unwrap_or_else(|poisoned| poisoned.into_inner())
21726 .is_empty());
21727 assert!(shared
21728 .materializations
21729 .lock()
21730 .unwrap_or_else(|poisoned| poisoned.into_inner())
21731 .is_empty());
21732 drop(shared);
21733 drop(runtime);
21734 std::fs::remove_dir_all(root).unwrap();
21735 }
21736
21737 #[test]
21738 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21739 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21740 let address = SessionAddress {
21741 workspace_id: WorkspaceId::new("primary").unwrap(),
21742 session: SessionKey {
21743 instance_id: AgentInstanceId(202),
21744 generation: SessionGeneration(1),
21745 },
21746 };
21747 let (overlay, materialization_guard) = shared
21748 .prepare_session_materialization(
21749 &address,
21750 &agent("claude"),
21751 SessionMode::Pty,
21752 Some(&receipt),
21753 None,
21754 None,
21755 None,
21756 )
21757 .unwrap()
21758 .unwrap();
21759 let materialization_id = materialization_guard.id().unwrap().clone();
21760 let materialization_root = shared
21761 .materializations
21762 .lock()
21763 .unwrap_or_else(|poisoned| poisoned.into_inner())
21764 .get(&materialization_id)
21765 .unwrap()
21766 .root()
21767 .to_path_buf();
21768 let selection = shared
21769 .select_environment_profile(
21770 address.session.instance_id,
21771 &agent("claude"),
21772 SessionMode::Pty,
21773 Some(&receipt),
21774 )
21775 .unwrap()
21776 .unwrap();
21777 assert!(shared
21778 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21779 .unwrap()
21780 .is_none());
21781 shared.bind_session_with_materialization(
21782 &address,
21783 ProviderRuntimePolicy::raw_pty(),
21784 Some(receipt),
21785 None,
21786 None,
21787 Some(materialization_id),
21788 );
21789 selection.retain();
21790 materialization_guard.retain();
21791
21792 assert!(materialization_root.is_dir());
21793 assert!(shared
21794 .session_bindings
21795 .lock()
21796 .unwrap_or_else(|poisoned| poisoned.into_inner())
21797 .contains_key(&address.session.instance_id));
21798
21799 let removed = shared.remove_binding(&address).unwrap();
21800 shared
21801 .cleanup_session_owned_materialization(&address, &removed)
21802 .unwrap();
21803 assert!(!materialization_root.exists());
21804 assert!(shared
21805 .materializations
21806 .lock()
21807 .unwrap_or_else(|poisoned| poisoned.into_inner())
21808 .is_empty());
21809 drop(shared);
21810 drop(runtime);
21811 std::fs::remove_dir_all(root).unwrap();
21812 }
21813
21814 #[tokio::test]
21815 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21816 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21817 let address = SessionAddress {
21818 workspace_id: WorkspaceId::new("primary").unwrap(),
21819 session: SessionKey {
21820 instance_id: AgentInstanceId(203),
21821 generation: SessionGeneration(1),
21822 },
21823 };
21824 let (overlay, materialization_guard) = shared
21825 .prepare_session_materialization(
21826 &address,
21827 &agent("claude"),
21828 SessionMode::Pty,
21829 Some(&receipt),
21830 None,
21831 None,
21832 None,
21833 )
21834 .unwrap()
21835 .unwrap();
21836 let materialization_id = materialization_guard.id().unwrap().clone();
21837 let materialization_root = shared
21838 .materializations
21839 .lock()
21840 .unwrap_or_else(|poisoned| poisoned.into_inner())
21841 .get(&materialization_id)
21842 .unwrap()
21843 .root()
21844 .to_path_buf();
21845 let selection = shared
21846 .select_environment_profile(
21847 address.session.instance_id,
21848 &agent("claude"),
21849 SessionMode::Pty,
21850 Some(&receipt),
21851 )
21852 .unwrap()
21853 .unwrap();
21854 assert!(shared
21855 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21856 .unwrap()
21857 .is_none());
21858 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21859 let record_id = shared
21860 .bind_spawn_session_with_materialization(
21861 &address,
21862 agent("claude"),
21863 SessionMode::Pty,
21864 policy,
21865 SpawnRecordPolicy::ProviderIdentityOnly,
21866 Some(receipt.clone()),
21867 None,
21868 None,
21869 Some(materialization_id.clone()),
21870 )
21871 .unwrap()
21872 .unwrap();
21873 selection.retain();
21874 materialization_guard.retain();
21875 assert_eq!(
21876 shared
21877 .materializations
21878 .lock()
21879 .unwrap_or_else(|poisoned| poisoned.into_inner())
21880 .get(&materialization_id)
21881 .unwrap()
21882 .owner(),
21883 &MaterializationOwner::Record {
21884 record_id: record_id.clone(),
21885 },
21886 );
21887
21888 let removed = shared.remove_binding(&address).unwrap();
21889 shared
21890 .cleanup_session_owned_materialization(&address, &removed)
21891 .unwrap();
21892 assert!(materialization_root.is_dir());
21893 assert!(shared
21894 .resolve_record_materialization(
21895 &record_id,
21896 &agent("claude"),
21897 SessionMode::Pty,
21898 Some(&receipt),
21899 None,
21900 None,
21901 )
21902 .unwrap()
21903 .is_some());
21904 {
21905 let mut records = shared
21906 .session_records
21907 .lock()
21908 .unwrap_or_else(|poisoned| poisoned.into_inner());
21909 let record = records.records.get_mut(&record_id).unwrap();
21910 record.active_session = None;
21911 record.state = ManagedSessionState::Unavailable;
21912 }
21913 shared.forget_session_record(&record_id).await.unwrap();
21914 assert!(!materialization_root.exists());
21915 assert!(shared
21916 .materializations
21917 .lock()
21918 .unwrap_or_else(|poisoned| poisoned.into_inner())
21919 .is_empty());
21920 drop(shared);
21921 drop(runtime);
21922 std::fs::remove_dir_all(root).unwrap();
21923 }
21924
21925 #[test]
21926 fn record_materialization_revalidation_failure_persists_recovery_required() {
21927 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21928 let state_path = root.join("node-state-v6.json");
21929 shared.state_path = Some(state_path.clone());
21930 let (_address, _record_id, materialization_id, materialization_root) =
21931 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21932 std::fs::write(
21933 materialization_root.join(".gate4agent-materialization-owner"),
21934 b"tampered",
21935 )
21936 .unwrap();
21937
21938 let error = match shared.resolve_record_materialization(
21939 &_record_id,
21940 &agent("claude"),
21941 SessionMode::Pty,
21942 Some(&receipt),
21943 None,
21944 None,
21945 ) {
21946 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21947 Err(error) => error,
21948 };
21949 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21950 assert_eq!(
21951 shared
21952 .materializations
21953 .lock()
21954 .unwrap_or_else(|poisoned| poisoned.into_inner())
21955 .get(&materialization_id)
21956 .unwrap()
21957 .state(),
21958 MaterializationState::RecoveryRequired,
21959 );
21960 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21961 assert_eq!(loaded.materializations.len(), 1);
21962 assert_eq!(
21963 loaded.materializations[0].state(),
21964 MaterializationState::RecoveryRequired,
21965 );
21966 drop(shared);
21967 drop(runtime);
21968 std::fs::remove_dir_all(root).unwrap();
21969 }
21970
21971 #[test]
21972 fn transient_record_materialization_resolution_failure_remains_ready() {
21973 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21974 let (_address, record_id, materialization_id, _materialization_root) =
21975 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21976 deny.store(true, Ordering::Release);
21977
21978 let error = match shared.resolve_record_materialization(
21979 &record_id,
21980 &agent("claude"),
21981 SessionMode::Pty,
21982 Some(&receipt),
21983 None,
21984 None,
21985 ) {
21986 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21987 Err(error) => error,
21988 };
21989 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21990 assert_eq!(
21991 shared
21992 .materializations
21993 .lock()
21994 .unwrap_or_else(|poisoned| poisoned.into_inner())
21995 .get(&materialization_id)
21996 .unwrap()
21997 .state(),
21998 MaterializationState::Ready,
21999 );
22000 drop(shared);
22001 drop(runtime);
22002 std::fs::remove_dir_all(root).unwrap();
22003 }
22004
22005 #[test]
22006 fn startup_missing_record_materialization_is_not_resumable() {
22007 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22008 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22009 shared
22010 .insert_record(ManagedSessionRecord {
22011 record_id: record_id.clone(),
22012 display_name: "missing materialization".to_owned(),
22013 provider: agent("claude"),
22014 mode: SessionMode::Pty,
22015 state: ManagedSessionState::Dormant,
22016 workspace_id: WorkspaceId::new("primary").unwrap(),
22017 canonical_root: opaque_windows_path(
22018 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22019 ),
22020 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22021 key: gate4agent_types::ProviderSessionKey::SessionId,
22022 id: "provider-session-missing-materialization".to_owned(),
22023 transcript_path: None,
22024 }),
22025 active_session: None,
22026 environment_profile: Some(receipt),
22027 bundle: None,
22028 context_id: None,
22029 context: None,
22030 exported_context: None,
22031 task_binding: None,
22032 created_at_unix_ms: 1,
22033 updated_at_unix_ms: 1,
22034 last_error: None,
22035 })
22036 .unwrap();
22037
22038 shared.reconcile_materializations();
22039 let record = shared.record(&record_id).unwrap();
22040 assert_eq!(record.state, ManagedSessionState::Unavailable);
22041 assert!(record.provider_session.is_none());
22042 assert_eq!(
22043 record.last_error.as_deref(),
22044 Some("environment-profile-unavailable"),
22045 );
22046 drop(shared);
22047 drop(runtime);
22048 std::fs::remove_dir_all(root).unwrap();
22049 }
22050
22051 #[tokio::test]
22052 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22053 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22054 let lease = install_managed_lease(&shared);
22055 let address = SessionAddress {
22056 workspace_id: WorkspaceId::new("primary").unwrap(),
22057 session: SessionKey {
22058 instance_id: AgentInstanceId(204),
22059 generation: SessionGeneration(1),
22060 },
22061 };
22062 let (overlay, materialization_guard) = shared
22063 .prepare_session_materialization(
22064 &address,
22065 &agent("claude"),
22066 SessionMode::Pty,
22067 Some(&receipt),
22068 None,
22069 None,
22070 Some(lease.lease_id.clone()),
22071 )
22072 .unwrap()
22073 .unwrap();
22074 let materialization_id = materialization_guard.id().unwrap().clone();
22075 let materialization_root = shared
22076 .materializations
22077 .lock()
22078 .unwrap_or_else(|poisoned| poisoned.into_inner())
22079 .get(&materialization_id)
22080 .unwrap()
22081 .root()
22082 .to_path_buf();
22083 let selection = shared
22084 .select_environment_profile(
22085 address.session.instance_id,
22086 &agent("claude"),
22087 SessionMode::Pty,
22088 Some(&receipt),
22089 )
22090 .unwrap()
22091 .unwrap();
22092 assert!(shared
22093 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22094 .unwrap()
22095 .is_none());
22096 shared.bind_session_with_materialization(
22097 &address,
22098 ProviderRuntimePolicy::raw_pty(),
22099 Some(receipt),
22100 None,
22101 None,
22102 Some(materialization_id),
22103 );
22104 selection.retain();
22105 materialization_guard.retain();
22106
22107 let blocked = shared
22108 .cleanup_managed_worktree(&lease.lease_id, false)
22109 .await
22110 .unwrap_err();
22111 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22112 assert!(materialization_root.is_dir());
22113 let removed = shared.remove_binding(&address).unwrap();
22114 shared
22115 .cleanup_session_owned_materialization(&address, &removed)
22116 .unwrap();
22117 assert!(!materialization_root.exists());
22118 drop(shared);
22119 drop(runtime);
22120 std::fs::remove_dir_all(root).unwrap();
22121 }
22122
22123 #[tokio::test]
22124 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22125 let git_root = temporary_workspace_root("raw-managed-inventory");
22126 let source = git_root.join("source");
22127 let allocation = git_root.join("allocation");
22128 std::fs::create_dir_all(&source).unwrap();
22129 std::fs::create_dir_all(&allocation).unwrap();
22130 run_test_git(&source, &["init"]);
22131 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22132 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22133 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22134 run_test_git(&source, &["add", "seed.txt"]);
22135 run_test_git(&source, &["commit", "-m", "seed"]);
22136 let profile = ManagedWorktreeProfile::new(
22137 WorktreeProfileId::new("default").unwrap(),
22138 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22139 &allocation,
22140 "gate4agent",
22141 "HEAD",
22142 ManagedWorktreeRetention::RemoveWhenReleased,
22143 )
22144 .unwrap();
22145 let workspace = WorkspaceConfig::new(
22146 WorkspaceId::new("primary").unwrap(),
22147 &source,
22148 )
22149 .unwrap()
22150 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22151 .with_managed_worktree_profile(profile.clone())
22152 .unwrap();
22153 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22154 materialization_test_shared_with_workspace(false, workspace);
22155 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22156 let source_text = source.to_string_lossy().into_owned();
22157 let target_text = target.to_string_lossy().into_owned();
22158 let created = create_git_worktree(
22159 &source_text,
22160 &target_text,
22161 "gate4agent/mw-raw-inventory",
22162 Some("HEAD"),
22163 )
22164 .await
22165 .unwrap();
22166 let lease = ManagedWorktreeLeaseRecord {
22167 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22168 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22169 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22170 profile_id: profile.profile_id().clone(),
22171 profile_revision: profile.revision().clone(),
22172 target_root: created.path.clone(),
22173 branch: "gate4agent/mw-raw-inventory".to_owned(),
22174 base_commit: created.head.clone(),
22175 expected_head: Some(created.head.clone()),
22176 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22177 state: ManagedWorktreeLeaseState::Ready,
22178 session_holders: Vec::new(),
22179 record_holders: Vec::new(),
22180 cleanup_failure: None,
22181 created_at_unix_ms: 1,
22182 updated_at_unix_ms: 1,
22183 };
22184 shared
22185 .workspaces
22186 .write()
22187 .unwrap_or_else(|poisoned| poisoned.into_inner())
22188 .insert(lease.workspace_id.clone(), created.path.clone());
22189 *shared
22190 .managed_worktrees
22191 .lock()
22192 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22193 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22194 let address = SessionAddress {
22195 workspace_id: lease.workspace_id.clone(),
22196 session: SessionKey {
22197 instance_id: AgentInstanceId(205),
22198 generation: SessionGeneration(1),
22199 },
22200 };
22201 let (overlay, materialization_guard) = shared
22202 .prepare_session_materialization(
22203 &address,
22204 &agent("claude"),
22205 SessionMode::Pty,
22206 Some(&receipt),
22207 None,
22208 None,
22209 Some(lease.lease_id.clone()),
22210 )
22211 .unwrap()
22212 .unwrap();
22213 let materialization_id = materialization_guard.id().unwrap().clone();
22214 let materialization_root = shared
22215 .materializations
22216 .lock()
22217 .unwrap_or_else(|poisoned| poisoned.into_inner())
22218 .get(&materialization_id)
22219 .unwrap()
22220 .root()
22221 .to_path_buf();
22222 let selection = shared
22223 .select_environment_profile(
22224 address.session.instance_id,
22225 &agent("claude"),
22226 SessionMode::Pty,
22227 Some(&receipt),
22228 )
22229 .unwrap()
22230 .unwrap();
22231 assert!(shared
22232 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22233 .unwrap()
22234 .is_none());
22235 let record_id = shared
22236 .bind_spawn_session_with_materialization(
22237 &address,
22238 agent("claude"),
22239 SessionMode::Pty,
22240 ProviderRuntimePolicy::raw_pty(),
22241 SpawnRecordPolicy::Always,
22242 Some(receipt.clone()),
22243 None,
22244 None,
22245 Some(materialization_id.clone()),
22246 )
22247 .unwrap()
22248 .unwrap();
22249 selection.retain();
22250 materialization_guard.retain();
22251 let record = shared.record(&record_id).unwrap();
22252 assert_eq!(record.state, ManagedSessionState::Live);
22253 assert!(record.provider_session.is_none());
22254 assert!(record.environment_profile.is_none());
22255 assert!(record.bundle.is_none());
22256 assert!(record.context_id.is_none());
22257 assert!(record.context.is_none());
22258 assert_eq!(record.active_session.as_ref(), Some(&address));
22259 assert_eq!(
22260 shared
22261 .materializations
22262 .lock()
22263 .unwrap_or_else(|poisoned| poisoned.into_inner())
22264 .get(&materialization_id)
22265 .unwrap()
22266 .owner(),
22267 &MaterializationOwner::Session {
22268 incarnation_id: shared.incarnation_id,
22269 instance_id: address.session.instance_id,
22270 generation: address.session.generation,
22271 },
22272 );
22273 {
22274 let mut bindings = shared
22275 .session_bindings
22276 .lock()
22277 .unwrap_or_else(|poisoned| poisoned.into_inner());
22278 bindings
22279 .get_mut(&address.session.instance_id)
22280 .unwrap()
22281 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22282 }
22283 let lease_snapshot = shared
22284 .managed_worktrees
22285 .lock()
22286 .unwrap_or_else(|poisoned| poisoned.into_inner())
22287 .bind_session(
22288 &lease.lease_id,
22289 ManagedWorktreeSessionHolder {
22290 incarnation_id: shared.incarnation_id,
22291 instance_id: address.session.instance_id,
22292 generation: address.session.generation,
22293 },
22294 None,
22295 unix_time_ms(),
22296 )
22297 .unwrap();
22298 assert_eq!(lease_snapshot.active_session_count, 1);
22299 assert_eq!(lease_snapshot.managed_record_count, 0);
22300 shared.persist_state().unwrap();
22301 assert!(shared
22302 .managed_worktrees
22303 .lock()
22304 .unwrap_or_else(|poisoned| poisoned.into_inner())
22305 .get(&lease.lease_id)
22306 .unwrap()
22307 .record_holders
22308 .is_empty());
22309 shared
22310 .handle
22311 .dispatch(shared.prepare_command(ControlCommand::Register {
22312 instance_id: address.session.instance_id,
22313 agent_id: agent("claude"),
22314 transport: TransportKind::Pty,
22315 }))
22316 .unwrap();
22317 runtime.tick().await;
22318 shared.reconcile_managed_record(
22319 &record_id,
22320 &address,
22321 &ControlEvent {
22322 sequence: 1,
22323 command_id: None,
22324 instance_id: address.session.instance_id,
22325 generation: address.session.generation,
22326 event: ControlEventKind::Removed,
22327 },
22328 false,
22329 false,
22330 );
22331 let remove = shared.remove_session(&address);
22332 let drive_runtime = async {
22333 runtime.tick().await;
22334 };
22335 let (removed, ()) = tokio::join!(remove, drive_runtime);
22336 removed.unwrap();
22337
22338 assert!(!materialization_root.exists());
22339 assert!(!Path::new(&created.path).exists());
22340 assert_eq!(
22341 shared
22342 .managed_worktrees
22343 .lock()
22344 .unwrap_or_else(|poisoned| poisoned.into_inner())
22345 .get(&lease.lease_id)
22346 .unwrap()
22347 .state,
22348 ManagedWorktreeLeaseState::Removed,
22349 );
22350 let renamed = shared
22351 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22352 .unwrap();
22353 assert_eq!(renamed.record_id, record_id);
22354 assert_eq!(renamed.display_name, "raw managed inventory");
22355 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22356 assert!(renamed.active_session.is_none());
22357
22358 drop(shared);
22359 drop(runtime);
22360 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22361 std::fs::remove_dir_all(git_root).unwrap();
22362 }
22363
22364 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22365 let root = std::env::current_dir().unwrap()
22366 .join("managed-test-target")
22367 .to_string_lossy()
22368 .into_owned();
22369 let lease = ManagedWorktreeLeaseRecord {
22370 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22371 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22372 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22373 profile_id: WorktreeProfileId::new("default").unwrap(),
22374 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22375 target_root: root.clone(),
22376 branch: "gate4agent/mw-test".to_owned(),
22377 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22378 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22379 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22380 state: ManagedWorktreeLeaseState::Ready,
22381 session_holders: Vec::new(),
22382 record_holders: Vec::new(),
22383 cleanup_failure: None,
22384 created_at_unix_ms: 1,
22385 updated_at_unix_ms: 1,
22386 };
22387 shared.workspaces.write()
22388 .unwrap_or_else(|poisoned| poisoned.into_inner())
22389 .insert(lease.workspace_id.clone(), root);
22390 *shared.managed_worktrees.lock()
22391 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22392 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22393 lease
22394 }
22395
22396 #[cfg(feature = "fixture")]
22397 #[test]
22398 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22399 let mut shared = terminal_test_shared();
22400 shared.fixture_semantic_hook_policy = true;
22401 let lease = install_managed_lease(&shared);
22402 let address = SessionAddress {
22403 workspace_id: lease.workspace_id.clone(),
22404 session: SessionKey {
22405 instance_id: AgentInstanceId(206),
22406 generation: SessionGeneration(1),
22407 },
22408 };
22409 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22410 let effective_runtime_policy =
22411 shared.effective_spawn_runtime_policy(pre_admission_policy);
22412 assert_ne!(effective_runtime_policy, pre_admission_policy);
22413 assert!(effective_runtime_policy.provider_session_identity);
22414
22415 let record_id = shared
22416 .bind_spawn_session(
22417 &address,
22418 agent("claude"),
22419 SessionMode::Pty,
22420 effective_runtime_policy,
22421 SpawnRecordPolicy::Always,
22422 None,
22423 )
22424 .unwrap()
22425 .unwrap();
22426 assert_eq!(
22427 shared.record(&record_id).unwrap().state,
22428 ManagedSessionState::IdentityPending,
22429 );
22430 let (record_holder, raw_inventory_record_id) = {
22431 let mut bindings = shared
22432 .session_bindings
22433 .lock()
22434 .unwrap_or_else(|poisoned| poisoned.into_inner());
22435 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22436 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22437 managed_spawn_record_ownership(binding, effective_runtime_policy)
22438 };
22439 assert_eq!(record_holder.as_ref(), Some(&record_id));
22440 assert!(raw_inventory_record_id.is_none());
22441 let snapshot = shared
22442 .managed_worktrees
22443 .lock()
22444 .unwrap_or_else(|poisoned| poisoned.into_inner())
22445 .bind_session(
22446 &lease.lease_id,
22447 ManagedWorktreeSessionHolder {
22448 incarnation_id: shared.incarnation_id,
22449 instance_id: address.session.instance_id,
22450 generation: address.session.generation,
22451 },
22452 record_holder,
22453 unix_time_ms(),
22454 )
22455 .unwrap();
22456 assert_eq!(snapshot.managed_record_count, 1);
22457 assert_eq!(
22458 shared
22459 .managed_worktrees
22460 .lock()
22461 .unwrap_or_else(|poisoned| poisoned.into_inner())
22462 .get(&lease.lease_id)
22463 .unwrap()
22464 .record_holders,
22465 vec![record_id.clone()],
22466 );
22467
22468 let identity = ProviderSessionIdentity {
22469 key: ProviderSessionKey::SessionId,
22470 id: "monitoring-fixture-managed-session".to_owned(),
22471 transcript_path: None,
22472 };
22473 shared.publish_control(ControlEvent {
22474 sequence: 1,
22475 command_id: None,
22476 instance_id: address.session.instance_id,
22477 generation: address.session.generation,
22478 event: ControlEventKind::ProviderEvent {
22479 sequence: 1,
22480 source: gate4agent_types::ProviderSource {
22481 family: AdapterFamily::Hook,
22482 binding: AdapterBinding::new(
22483 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22484 "fixture/v1",
22485 gate4agent_types::AdapterVerification::SyntheticFixture,
22486 )
22487 .unwrap(),
22488 },
22489 source_sequence: 1,
22490 event: ProviderEvent::SessionIdentityObserved {
22491 identity: identity.clone(),
22492 },
22493 },
22494 });
22495
22496 let live = shared.record(&record_id).unwrap();
22497 assert_eq!(live.state, ManagedSessionState::Live);
22498 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22499 assert_eq!(live.active_session.as_ref(), Some(&address));
22500 assert_eq!(
22501 shared
22502 .managed_worktrees
22503 .lock()
22504 .unwrap_or_else(|poisoned| poisoned.into_inner())
22505 .get(&lease.lease_id)
22506 .unwrap()
22507 .record_holders,
22508 vec![record_id],
22509 );
22510 }
22511
22512 fn managed_v2_test_shared(
22513 allocation_root: &Path,
22514 state_path: Option<PathBuf>,
22515 profile_revision: &str,
22516 ) -> NodeShared {
22517 let profile = ManagedWorktreeProfile::new(
22518 WorktreeProfileId::new("default").unwrap(),
22519 WorktreeProfileRevision::new(profile_revision).unwrap(),
22520 allocation_root,
22521 "gate4agent",
22522 "HEAD",
22523 ManagedWorktreeRetention::RemoveWhenReleased,
22524 )
22525 .unwrap();
22526 let workspace = WorkspaceConfig::new(
22527 WorkspaceId::new("primary").unwrap(),
22528 std::env::current_dir().unwrap(),
22529 )
22530 .unwrap()
22531 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22532 .with_managed_worktree_profile(profile)
22533 .unwrap();
22534 let catalog = active_registry().unwrap();
22535 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22536 let mut shared = NodeShared::new(
22537 handle,
22538 "fixture-token".to_owned(),
22539 NodeId::new("node-terminal-test").unwrap(),
22540 vec![workspace],
22541 vec![agent("claude")],
22542 );
22543 shared.state_path = state_path;
22544 shared
22545 }
22546
22547 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22548 let mut spawn_spec = spawn_spec_fixture();
22549 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22550 ManagedWorktreeSpawnRequestV2 {
22551 spawn_spec,
22552 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22553 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22554 }
22555 }
22556
22557 #[test]
22558 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22559 let shared = terminal_test_shared();
22560 let mut lease = install_managed_lease(&shared);
22561 let session = SessionAddress {
22562 workspace_id: lease.workspace_id.clone(),
22563 session: SessionKey {
22564 instance_id: AgentInstanceId(73),
22565 generation: SessionGeneration(4),
22566 },
22567 };
22568 lease.state = ManagedWorktreeLeaseState::InUse;
22569 lease.session_holders.push(ManagedWorktreeSessionHolder {
22570 incarnation_id: shared.incarnation_id,
22571 instance_id: session.session.instance_id,
22572 generation: session.session.generation,
22573 });
22574 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22575 assert!(resolved.target.worktree_id.is_none());
22576
22577 let receipt = managed_spawn_receipt(
22578 &resolved,
22579 shared.incarnation_id,
22580 session.clone(),
22581 lease.snapshot(),
22582 None,
22583 None,
22584 None,
22585 );
22586 assert!(resolved.target.worktree_id.is_none());
22587 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22588 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22589 assert_eq!(receipt.spawn.session, session);
22590 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22591 let encoded = serde_json::to_vec(&receipt).unwrap();
22592 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22593 assert_eq!(decoded, receipt);
22594 }
22595
22596 #[tokio::test]
22597 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22598 let root = temporary_workspace_root("managed-v2-revision");
22599 let allocation = root.join("allocation");
22600 std::fs::create_dir_all(&allocation).unwrap();
22601 let shared = managed_v2_test_shared(
22602 &allocation,
22603 Some(root.join("node-state.json")),
22604 "v1",
22605 );
22606 let mut request = managed_v2_request("managed-v2-revision");
22607 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22608
22609 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22610
22611 assert_eq!(
22612 failure.code,
22613 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22614 );
22615 assert!(shared
22616 .managed_worktrees
22617 .lock()
22618 .unwrap_or_else(|poisoned| poisoned.into_inner())
22619 .records()
22620 .is_empty());
22621 assert!(shared
22622 .managed_spawn_replays
22623 .lock()
22624 .unwrap_or_else(|poisoned| poisoned.into_inner())
22625 .is_empty());
22626 std::fs::remove_dir_all(root).unwrap();
22627 }
22628
22629 #[test]
22630 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22631 let root = temporary_workspace_root("managed-v2-concurrent");
22632 let allocation = root.join("allocation");
22633 std::fs::create_dir_all(&allocation).unwrap();
22634 let shared = Arc::new(managed_v2_test_shared(
22635 &allocation,
22636 Some(root.join("node-state.json")),
22637 "v1",
22638 ));
22639 let request = managed_v2_request("managed-v2-concurrent");
22640 let barrier = Arc::new(std::sync::Barrier::new(3));
22641 let mut joins = Vec::new();
22642 for _ in 0..2 {
22643 let shared = Arc::clone(&shared);
22644 let request = request.clone();
22645 let barrier = Arc::clone(&barrier);
22646 joins.push(std::thread::spawn(move || {
22647 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22648 barrier.wait();
22649 shared.acquire_durable_managed_spawn_v2(&request, digest)
22650 }));
22651 }
22652 barrier.wait();
22653 let results = joins
22654 .into_iter()
22655 .map(|join| join.join().unwrap())
22656 .collect::<Vec<_>>();
22657 assert_eq!(
22658 results
22659 .iter()
22660 .filter(|result| {
22661 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22662 })
22663 .count(),
22664 1,
22665 );
22666 assert_eq!(
22667 results
22668 .iter()
22669 .filter(|result| {
22670 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22671 })
22672 .count(),
22673 1,
22674 );
22675 assert_eq!(
22676 shared
22677 .managed_spawn_replays
22678 .lock()
22679 .unwrap_or_else(|poisoned| poisoned.into_inner())
22680 .len(),
22681 1,
22682 );
22683 assert!(shared
22684 .managed_worktrees
22685 .lock()
22686 .unwrap_or_else(|poisoned| poisoned.into_inner())
22687 .records()
22688 .is_empty());
22689 drop(shared);
22690 std::fs::remove_dir_all(root).unwrap();
22691 }
22692
22693 #[tokio::test]
22694 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22695 let root = temporary_workspace_root("managed-v2-linked-pending");
22696 let allocation = root.join("allocation");
22697 std::fs::create_dir_all(&allocation).unwrap();
22698 let state_path = root.join("node-state.json");
22699 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22700 let lease = install_managed_lease(&shared);
22701 let request = managed_v2_request("managed-v2-linked-pending");
22702 assert!(matches!(
22703 shared
22704 .acquire_durable_managed_spawn_v2(
22705 &request,
22706 managed_spawn_request_digest_v2(&request).unwrap(),
22707 )
22708 .unwrap(),
22709 DurableManagedSpawnReplayDecision::Reserved,
22710 ));
22711 {
22712 let _transaction = shared
22713 .state_transaction
22714 .lock()
22715 .unwrap_or_else(|poisoned| poisoned.into_inner());
22716 shared
22717 .link_durable_managed_spawn_v2_lease_locked(
22718 &request.spawn_spec.idempotency_key,
22719 &lease.lease_id,
22720 )
22721 .unwrap();
22722 shared.persist_state_locked().unwrap();
22723 }
22724 drop(shared);
22725
22726 let mut loaded = session_registry::load(
22727 Some(&state_path),
22728 &NodeId::new("node-terminal-test").unwrap(),
22729 )
22730 .unwrap();
22731 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22732 *reopened
22733 .managed_worktrees
22734 .lock()
22735 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22736 ManagedWorktreeRegistry::from_records(
22737 std::mem::take(&mut loaded.managed_worktrees),
22738 std::mem::take(&mut loaded.managed_worktree_tombstones),
22739 )
22740 .unwrap();
22741 *reopened
22742 .managed_spawn_replays
22743 .lock()
22744 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22745 .managed_spawn_replays
22746 .into_iter()
22747 .map(|record| (record.idempotency_key.clone(), record))
22748 .collect();
22749 let lease_count = reopened
22750 .managed_worktrees
22751 .lock()
22752 .unwrap_or_else(|poisoned| poisoned.into_inner())
22753 .records()
22754 .len();
22755
22756 let failure = reopened
22757 .spawn_managed_worktree_v2(request)
22758 .await
22759 .unwrap_err();
22760
22761 assert_eq!(
22762 failure.code,
22763 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22764 );
22765 assert_eq!(
22766 reopened
22767 .managed_worktrees
22768 .lock()
22769 .unwrap_or_else(|poisoned| poisoned.into_inner())
22770 .records()
22771 .len(),
22772 lease_count,
22773 );
22774 std::fs::remove_dir_all(root).unwrap();
22775 }
22776
22777 #[tokio::test]
22778 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22779 let root = temporary_workspace_root("managed-v2-reopen");
22780 let allocation = root.join("allocation");
22781 std::fs::create_dir_all(&allocation).unwrap();
22782 let state_path = root.join("node-state.json");
22783 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22784 let lease = install_managed_lease(&shared);
22785 let request = managed_v2_request("managed-v2-reopen");
22786 let session = SessionAddress {
22787 workspace_id: lease.workspace_id.clone(),
22788 session: SessionKey {
22789 instance_id: AgentInstanceId(91),
22790 generation: SessionGeneration(1),
22791 },
22792 };
22793 let snapshot = {
22794 let mut registry = shared
22795 .managed_worktrees
22796 .lock()
22797 .unwrap_or_else(|poisoned| poisoned.into_inner());
22798 let current = registry.get_mut(&lease.lease_id).unwrap();
22799 current.state = ManagedWorktreeLeaseState::InUse;
22800 current.session_holders.push(ManagedWorktreeSessionHolder {
22801 incarnation_id: shared.incarnation_id,
22802 instance_id: session.session.instance_id,
22803 generation: session.session.generation,
22804 });
22805 current.snapshot()
22806 };
22807 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22808 let receipt = managed_spawn_receipt(
22809 &resolved,
22810 shared.incarnation_id,
22811 session,
22812 snapshot,
22813 None,
22814 None,
22815 None,
22816 );
22817 assert!(matches!(
22818 shared
22819 .acquire_durable_managed_spawn_v2(
22820 &request,
22821 managed_spawn_request_digest_v2(&request).unwrap(),
22822 )
22823 .unwrap(),
22824 DurableManagedSpawnReplayDecision::Reserved,
22825 ));
22826 {
22827 let _transaction = shared
22828 .state_transaction
22829 .lock()
22830 .unwrap_or_else(|poisoned| poisoned.into_inner());
22831 shared
22832 .link_durable_managed_spawn_v2_lease_locked(
22833 &request.spawn_spec.idempotency_key,
22834 &lease.lease_id,
22835 )
22836 .unwrap();
22837 shared.persist_state_locked().unwrap();
22838 }
22839 shared
22840 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22841 .unwrap();
22842 let legacy_same_key = ManagedWorktreeSpawnRequest {
22843 spawn_spec: request.spawn_spec.clone(),
22844 worktree_profile_id: request.worktree_profile_id.clone(),
22845 };
22846 shared.remember_managed_spawn_attempt(
22847 Some(&request.spawn_spec.idempotency_key),
22848 legacy_same_key.clone(),
22849 Ok(receipt.clone()),
22850 Instant::now(),
22851 );
22852 assert!(shared
22853 .replay_managed_spawn(&legacy_same_key, Instant::now())
22854 .unwrap()
22855 .is_none());
22856 drop(shared);
22857
22858 let mut loaded = session_registry::load(
22859 Some(&state_path),
22860 &NodeId::new("node-terminal-test").unwrap(),
22861 )
22862 .unwrap();
22863 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22864 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22865 *reopened
22866 .managed_worktrees
22867 .lock()
22868 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22869 ManagedWorktreeRegistry::from_records(
22870 std::mem::take(&mut loaded.managed_worktrees),
22871 std::mem::take(&mut loaded.managed_worktree_tombstones),
22872 )
22873 .unwrap();
22874 *reopened
22875 .managed_spawn_replays
22876 .lock()
22877 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22878 .managed_spawn_replays
22879 .into_iter()
22880 .map(|record| (record.idempotency_key.clone(), record))
22881 .collect();
22882 let lease_count_before = reopened
22883 .managed_worktrees
22884 .lock()
22885 .unwrap_or_else(|poisoned| poisoned.into_inner())
22886 .records()
22887 .len();
22888
22889 assert_eq!(
22890 reopened
22891 .spawn_managed_worktree_v2(request.clone())
22892 .await
22893 .unwrap(),
22894 receipt,
22895 );
22896 assert_eq!(
22897 reopened
22898 .managed_worktrees
22899 .lock()
22900 .unwrap_or_else(|poisoned| poisoned.into_inner())
22901 .records()
22902 .len(),
22903 lease_count_before,
22904 );
22905 let mut changed = request;
22906 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22907 let failure = reopened
22908 .spawn_managed_worktree_v2(changed)
22909 .await
22910 .unwrap_err();
22911 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22912 assert_eq!(
22913 reopened
22914 .managed_worktrees
22915 .lock()
22916 .unwrap_or_else(|poisoned| poisoned.into_inner())
22917 .records()
22918 .len(),
22919 lease_count_before,
22920 );
22921 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22922 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22923 let failure = reopened
22924 .spawn_managed_worktree_v2(new_key)
22925 .await
22926 .unwrap_err();
22927 assert_eq!(
22928 failure.code,
22929 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22930 );
22931 assert_eq!(
22932 reopened
22933 .managed_worktrees
22934 .lock()
22935 .unwrap_or_else(|poisoned| poisoned.into_inner())
22936 .records()
22937 .len(),
22938 lease_count_before,
22939 );
22940 assert_eq!(
22941 reopened
22942 .managed_spawn_replays
22943 .lock()
22944 .unwrap_or_else(|poisoned| poisoned.into_inner())
22945 .len(),
22946 1,
22947 );
22948 std::fs::remove_dir_all(root).unwrap();
22949 }
22950
22951 #[test]
22952 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22953 let shared = terminal_test_shared();
22954 let request = ManagedWorktreeSpawnRequest {
22955 spawn_spec: spawn_spec_fixture(),
22956 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22957 };
22958 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22959 shared.remember_managed_spawn(
22960 request.clone(),
22961 Err(expected.clone()),
22962 Instant::now(),
22963 );
22964
22965 assert_eq!(
22966 shared
22967 .replay_managed_spawn(&request, Instant::now())
22968 .unwrap()
22969 .unwrap(),
22970 Err(expected),
22971 );
22972 assert!(shared
22973 .managed_spawn_replays
22974 .lock()
22975 .unwrap_or_else(|poisoned| poisoned.into_inner())
22976 .is_empty());
22977 }
22978
22979 #[tokio::test]
22980 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22981 let shared = terminal_test_shared();
22982 let lease = install_managed_lease(&shared);
22983 let raw = shared.spawn_session(
22984 lease.workspace_id.clone(),
22985 agent("claude"),
22986 SessionMode::Pty,
22987 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22988 None,
22989 ).await.unwrap_err();
22990 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22991 let mut spec = spawn_spec_fixture();
22992 spec.target.workspace_id = lease.workspace_id.clone();
22993 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22994 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22995 let mut selected = spawn_spec_fixture();
22996 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22997 selected.target.worktree_id = Some(lease.workspace_id);
22998 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22999 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23000 assert!(shared.handle.snapshot().sessions.is_empty());
23001 }
23002
23003 #[test]
23004 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23005 let shared = terminal_test_shared();
23006 let lease = install_managed_lease(&shared);
23007 let snapshot = snapshot_for_wire(
23008 &shared, true, true, true, false, true, true, false, false, true,
23009 );
23010 assert!(snapshot.managed_worktrees.is_empty());
23011 assert!(snapshot.workspaces.iter().all(|workspace| {
23012 workspace.managed_worktree_profiles.is_none()
23013 }));
23014 let mut reply = ResponseEnvelope {
23015 request_id: 1,
23016 result: Ok(NodeResponse::Resync {
23017 event_sequence: 1,
23018 oldest_available_sequence: 1,
23019 snapshot: shared.snapshot(),
23020 events: vec![NodeEventEnvelope {
23021 sequence: 1,
23022 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23023 }],
23024 }),
23025 };
23026 project_response_without_managed_worktrees(&mut reply);
23027 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23028 panic!("projection changed the response kind");
23029 };
23030 assert!(snapshot.managed_worktrees.is_empty());
23031 assert!(events.is_empty());
23032 let mut git = git_snapshot_for_parser();
23033 git.branch = Some(lease.branch.clone());
23034 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23035 lease_id: lease.lease_id.clone(),
23036 source_workspace_id: lease.source_workspace_id.clone(),
23037 branch: lease.branch.clone(),
23038 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23039 active_session_count: 1,
23040 managed_record_count: 0,
23041 });
23042 let mut inspection_reply = ResponseEnvelope {
23043 request_id: 2,
23044 result: Ok(NodeResponse::WorkspaceInspected {
23045 inspection: WorkspaceInspection {
23046 workspace_id: lease.workspace_id.clone(),
23047 entries: Vec::new(),
23048 tree_truncated: false,
23049 git,
23050 truncation: None,
23051 },
23052 }),
23053 };
23054 project_response_without_managed_worktrees(&mut inspection_reply);
23055 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23056 panic!("projection changed the inspection response kind");
23057 };
23058 assert!(inspection.git.managed_worktree.is_none());
23059 let failure = managed_git_worktree_failure(GitWorktreeError {
23060 kind: GitWorktreeErrorKind::Failed,
23061 message: r"git failed at C:\private\managed-target".to_owned(),
23062 });
23063 assert!(!failure.message.contains("private"));
23064 assert!(!failure.message.contains("managed-target"));
23065 }
23066
23067 #[tokio::test]
23068 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23069 let root = temporary_workspace_root("managed-restart");
23070 let source = root.join("source");
23071 let allocation = root.join("allocation");
23072 std::fs::create_dir_all(&source).unwrap();
23073 std::fs::create_dir_all(&allocation).unwrap();
23074 run_test_git(&source, &["init"]);
23075 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23076 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23077 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23078 run_test_git(&source, &["add", "seed.txt"]);
23079 run_test_git(&source, &["commit", "-m", "seed"]);
23080 let profile = ManagedWorktreeProfile::new(
23081 WorktreeProfileId::new("default").unwrap(),
23082 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23083 &allocation,
23084 "gate4agent",
23085 "HEAD",
23086 ManagedWorktreeRetention::Retain,
23087 ).unwrap();
23088 let workspace = WorkspaceConfig::new(
23089 WorkspaceId::new("primary").unwrap(),
23090 &source,
23091 ).unwrap()
23092 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23093 .with_managed_worktree_profile(profile.clone()).unwrap();
23094 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23095 let source_text = workspace.canonical_root().to_owned();
23096 let target_text = target.to_string_lossy().into_owned();
23097 let created = create_git_worktree(
23098 &source_text,
23099 &target_text,
23100 "gate4agent/mw-restart",
23101 Some("HEAD"),
23102 ).await.unwrap();
23103 let lease = ManagedWorktreeLeaseRecord {
23104 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23105 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23106 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23107 profile_id: profile.profile_id().clone(),
23108 profile_revision: profile.revision().clone(),
23109 target_root: created.path.clone(),
23110 branch: "gate4agent/mw-restart".to_owned(),
23111 base_commit: created.head.clone(),
23112 expected_head: None,
23113 retention: ManagedWorktreeRetention::Retain,
23114 state: ManagedWorktreeLeaseState::Allocating,
23115 session_holders: Vec::new(),
23116 record_holders: Vec::new(),
23117 cleanup_failure: None,
23118 created_at_unix_ms: 1,
23119 updated_at_unix_ms: 1,
23120 };
23121 let catalog = active_registry().unwrap();
23122 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23123 let shared = NodeShared::new_with_incarnation(
23124 handle,
23125 "fixture-token".to_owned(),
23126 NodeId::new("node-managed-restart").unwrap(),
23127 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23128 vec![workspace],
23129 vec![agent("claude")],
23130 ProviderRuntimeStatuses::default(),
23131 None,
23132 Vec::new(),
23133 Vec::new(),
23134 SpawnProfileRegistry::default(),
23135 None,
23136 None,
23137 None,
23138 None,
23139 Vec::new(),
23140 vec![lease.clone()],
23141 Vec::new(),
23142 Vec::new(),
23143 None,
23144 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23145 );
23146 shared.reconcile_managed_worktrees().await;
23147 let retained = shared.managed_worktrees.lock()
23148 .unwrap_or_else(|poisoned| poisoned.into_inner())
23149 .get(&lease.lease_id).unwrap().clone();
23150 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23151 assert!(shared.workspaces.read()
23152 .unwrap_or_else(|poisoned| poisoned.into_inner())
23153 .contains_key(&lease.workspace_id));
23154 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23155 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23156 assert!(!Path::new(&created.path).exists());
23157 std::fs::remove_dir_all(root).unwrap();
23158 }
23159
23160 fn run_test_git(root: &Path, arguments: &[&str]) {
23161 let output = std::process::Command::new("git")
23162 .arg("-C").arg(root).args(arguments).output().unwrap();
23163 assert!(
23164 output.status.success(),
23165 "git {:?} failed: {}",
23166 arguments,
23167 String::from_utf8_lossy(&output.stderr),
23168 );
23169 }
23170
23171 #[tokio::test]
23172 async fn spawn_spec_validation_fails_before_session_mutation() {
23173 let shared = terminal_test_shared();
23174 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23175 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23176 }));
23177 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23178 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23179 }));
23180 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23181 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23182 }));
23183
23184 let mut target_mismatch = spawn_spec_fixture();
23185 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23186 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23187 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23188
23189 let mut unknown_profile = spawn_spec_fixture();
23190 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23191 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23192 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23193 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23194
23195 let mut revision_mismatch = spawn_spec_fixture();
23196 revision_mismatch.idempotency_key =
23197 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23198 revision_mismatch.expected_profile_revision =
23199 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23200 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23201 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23202
23203 let mut unsupported_materializer = spawn_spec_fixture();
23204 unsupported_materializer.idempotency_key =
23205 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23206 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23207 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23208 };
23209 let failure = shared
23210 .spawn_from_spec(unsupported_materializer)
23211 .await
23212 .unwrap_err();
23213 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23214
23215 let failure = shared
23216 .spawn_session_with_deadline(
23217 WorkspaceId::new("primary").unwrap(),
23218 agent("claude"),
23219 SessionMode::Pty,
23220 gate4agent_types::TerminalSize {
23221 rows: 24,
23222 columns: 80,
23223 },
23224 None,
23225 None,
23226 None,
23227 None,
23228 None,
23229 None,
23230 SpawnRecordPolicy::ProviderIdentityOnly,
23231 Some(Instant::now()),
23232 &[],
23233 None,
23234 ApprovalLevel::default(),
23235 None,
23236 None,
23237 )
23238 .await
23239 .unwrap_err();
23240 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23241 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23242 assert!(shared.handle.snapshot().sessions.is_empty());
23243 }
23244
23245 #[test]
23246 fn worktree_selection_requires_its_separate_negotiated_capability() {
23247 let mut spec = spawn_spec_fixture();
23248 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23249 let request = NodeRequest::SpawnSpec { spec };
23250 let spawn_spec_capability =
23251 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23252 assert!(request_uses_unnegotiated_capability(
23253 &request,
23254 &[spawn_spec_capability.clone()],
23255 ));
23256 let worktree_capability =
23257 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23258 assert!(request_uses_unnegotiated_capability(
23259 &request,
23260 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23261 ));
23262 let profile_revision_capability =
23263 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23264 assert!(request_uses_unnegotiated_capability(
23265 &request,
23266 &[
23267 spawn_spec_capability.clone(),
23268 worktree_capability.clone(),
23269 profile_revision_capability.clone(),
23270 ],
23271 ));
23272 let bundle_materialization_capability =
23279 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23280 assert!(request_uses_unnegotiated_capability(
23281 &request,
23282 &[
23283 spawn_spec_capability.clone(),
23284 worktree_capability.clone(),
23285 profile_revision_capability.clone(),
23286 bundle_materialization_capability.clone(),
23287 ],
23288 ));
23289 let history_context_pack_capability =
23290 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23291 assert!(!request_uses_unnegotiated_capability(
23292 &request,
23293 &[
23294 spawn_spec_capability,
23295 worktree_capability,
23296 profile_revision_capability,
23297 bundle_materialization_capability,
23298 history_context_pack_capability,
23299 ],
23300 ));
23301 }
23302
23303 #[tokio::test]
23304 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23305 let mut shared = terminal_test_shared();
23306 let spec = spawn_spec_fixture();
23307 let receipt = shared
23308 .resolve_spawn_spec(&spec)
23309 .unwrap()
23310 .receipt(shared.incarnation_id, terminal_address(1));
23311 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23312
23313 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23314 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23315 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23316
23317 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23318
23319 let mut revision_conflicting = spec.clone();
23320 revision_conflicting.expected_profile_revision =
23321 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23322 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23323 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23324
23325 let mut stale_cache_miss = spec.clone();
23326 stale_cache_miss.idempotency_key =
23327 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23328 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23329 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23330
23331 let mut conflicting = spec;
23332 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23333 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23334 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23335 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23336 assert!(shared.handle.snapshot().sessions.is_empty());
23337 }
23338
23339 #[tokio::test]
23340 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23341 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23342 let mut spec = spawn_spec_fixture();
23343 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23344 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23345 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23346
23347 let failure = shared
23348 .create_worktree(
23349 WorkspaceId::new("primary").unwrap(),
23350 WorkspaceId::new("new-worktree").unwrap(),
23351 "not-an-absolute-path".to_owned(),
23352 "invalid branch".to_owned(),
23353 None,
23354 )
23355 .await
23356 .unwrap_err();
23357 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23358
23359 let failure = shared
23360 .remove_worktree(
23361 WorkspaceId::new("primary").unwrap(),
23362 "not-an-absolute-path".to_owned(),
23363 )
23364 .await
23365 .unwrap_err();
23366 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23367 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23368 assert!(shared.handle.snapshot().sessions.is_empty());
23369 }
23370
23371 #[tokio::test]
23372 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23373 let root = temporary_workspace_root("manual-worktree-compensation");
23374 let source = root.join("source");
23375 let target = root.join("target");
23376 let invalid_state_parent = root.join("state-parent-is-a-file");
23377 let invalid_state_path = invalid_state_parent.join("state.json");
23378 std::fs::create_dir_all(&source).unwrap();
23379 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23380 run_test_git(&source, &["init"]);
23381 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23382 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23383 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23384 run_test_git(&source, &["add", "seed.txt"]);
23385 run_test_git(&source, &["commit", "-m", "seed"]);
23386
23387 let catalog = active_registry().unwrap();
23388 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23389 let workspace = WorkspaceConfig::new(
23390 WorkspaceId::new("primary").unwrap(),
23391 &source,
23392 ).unwrap();
23393 let mut shared = NodeShared::new(
23394 handle,
23395 "fixture-token".to_owned(),
23396 NodeId::new("node-worktree-compensation").unwrap(),
23397 vec![workspace],
23398 vec![agent("claude")],
23399 );
23400 shared.state_path = Some(invalid_state_path);
23401
23402 let error = shared.create_worktree(
23403 WorkspaceId::new("primary").unwrap(),
23404 WorkspaceId::new("compensated").unwrap(),
23405 target.to_string_lossy().into_owned(),
23406 "gate4agent/compensated".to_owned(),
23407 Some("HEAD".to_owned()),
23408 ).await.unwrap_err();
23409
23410 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23411 assert!(!target.exists());
23412 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23413 .iter()
23414 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23415 std::fs::remove_dir_all(root).unwrap();
23416 }
23417
23418 #[test]
23419 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23420 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23421 NativeGitWorktreeSnapshot {
23422 path: path.to_owned(),
23423 head: "0123456789abcdef".to_owned(),
23424 branch: Some("refs/heads/test".to_owned()),
23425 is_bare,
23426 is_main,
23427 locked: false,
23428 lock_reason: None,
23429 prunable: false,
23430 prunable_reason: None,
23431 workspace_id: None,
23432 }
23433 }
23434
23435 let source = r"C:\repo";
23436 let selected = r"C:\repo-worktree";
23437 let listed = vec![
23438 worktree(source, true, false),
23439 worktree(selected, false, false),
23440 ];
23441 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23442
23443 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23444 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23445
23446 let bare = vec![
23447 worktree(source, true, false),
23448 worktree(selected, false, true),
23449 ];
23450 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23451 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23452
23453 let foreign_failure =
23454 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23455 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23456 }
23457
23458 fn terminal_frame(sequence: u64) -> TerminalFrame {
23459 TerminalFrame {
23460 sequence,
23461 size: gate4agent_types::TerminalSize {
23462 rows: 24,
23463 columns: 80,
23464 },
23465 cursor_row: 2,
23466 cursor_column: 3,
23467 contents: format!("frame-{sequence}"),
23468 formatted: vec![1, 2, 3],
23469 scrollback_formatted: Vec::new(),
23470 alternate_screen: false,
23471 mouse_protocol_enabled: false,
23472 mouse_protocol_encoding: Default::default(),
23473 produced_at_unix_ms: 0,
23474 screen_state: gate4agent_types::PtyScreenState::default(),
23475 bracketed_paste: None,
23476 }
23477 }
23478
23479 #[test]
23480 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23481 let shared = terminal_test_shared();
23482 let mut receiver = shared.terminal_event_tx.subscribe();
23483 let original = terminal_address(1);
23484
23485 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23486 let first = receiver.try_recv().unwrap();
23487 assert_eq!(first.len(), 1);
23488 assert!(matches!(
23489 &first[0].event,
23490 NodeEvent::TerminalFrame { address, frame }
23491 if address == &original && frame.sequence == 7
23492 ));
23493
23494 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23495 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23496 assert!(matches!(
23497 receiver.try_recv(),
23498 Err(broadcast::error::TryRecvError::Empty)
23499 ));
23500
23501 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23502 let advanced = receiver.try_recv().unwrap();
23503 assert_eq!(advanced[0].sequence, 2);
23504
23505 let resumed = terminal_address(2);
23506 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23507 let reset = receiver.try_recv().unwrap();
23508 assert_eq!(reset[0].sequence, 3);
23509 assert!(matches!(
23510 &reset[0].event,
23511 NodeEvent::TerminalFrame { address, frame }
23512 if address == &resumed && frame.sequence == 1
23513 ));
23514 shared.clear_terminal_frame_watermark(&resumed);
23515 assert!(!shared
23516 .terminal_frame_watermarks
23517 .lock()
23518 .unwrap_or_else(|poisoned| poisoned.into_inner())
23519 .contains_key(&resumed.session.instance_id));
23520 }
23521
23522 #[test]
23523 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23524 let shared = terminal_test_shared();
23525 let mut receiver = shared.terminal_event_tx.subscribe();
23526 let address = terminal_address(1);
23527 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23528
23529 for sequence in 1..=count {
23530 shared.publish_terminal_frame_candidates(vec![(
23531 address.clone(),
23532 terminal_frame(sequence),
23533 )]);
23534 }
23535
23536 let history = shared
23537 .history
23538 .lock()
23539 .unwrap_or_else(|poisoned| poisoned.into_inner());
23540 assert!(history.events.is_empty());
23541 assert_eq!(history.last_sequence, count);
23542 assert_eq!(history.replay_floor_sequence, 1);
23543 drop(history);
23544 let NodeResponse::Resync {
23545 event_sequence,
23546 oldest_available_sequence,
23547 events,
23548 ..
23549 } = shared.resync(0) else {
23550 unreachable!("resync helper returned another response")
23551 };
23552 assert_eq!(event_sequence, count);
23553 assert_eq!(oldest_available_sequence, 1);
23554 assert!(events.is_empty());
23555 assert!(matches!(
23556 resync_required_event(&shared).event,
23557 NodeEvent::ResyncRequired {
23558 oldest_available_sequence: 1,
23559 }
23560 ));
23561 assert!(matches!(
23562 receiver.try_recv(),
23563 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23564 ));
23565 let latest = receiver.try_recv().unwrap();
23566 assert_eq!(latest.len(), 1);
23567 assert_eq!(latest[0].sequence, count);
23568 }
23569
23570 #[test]
23571 fn real_durable_eviction_advances_authoritative_replay_floor() {
23572 let shared = terminal_test_shared();
23573 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23574 shared.publish(NodeEvent::ControllerChanged { controller: None });
23575 }
23576
23577 let NodeResponse::Resync {
23578 event_sequence,
23579 oldest_available_sequence,
23580 events,
23581 ..
23582 } = shared.resync(0) else {
23583 unreachable!("resync helper returned another response")
23584 };
23585 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23586 assert_eq!(oldest_available_sequence, 2);
23587 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23588 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23589 assert!(matches!(
23590 resync_required_event(&shared).event,
23591 NodeEvent::ResyncRequired {
23592 oldest_available_sequence: 2,
23593 }
23594 ));
23595 }
23596
23597 #[test]
23598 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23599 let shared = terminal_test_shared();
23600 let without_capability = terminal_event_subscription(&shared, false);
23601 let with_capability = terminal_event_subscription(&shared, true);
23602 assert!(without_capability.is_none());
23603 assert!(with_capability.is_some());
23604 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23605 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23606 }));
23607 }
23608
23609 #[test]
23610 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23611 let mut pending = Vec::new();
23612 let mut event_sequence = 0_u64;
23613 for frame_sequence in 1..=4 {
23614 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23615 event_sequence += 1;
23616 let mut address = terminal_address(1);
23617 address.session.instance_id = AgentInstanceId(instance);
23618 queue_connection_event(
23619 &mut pending,
23620 NodeEventEnvelope {
23621 sequence: event_sequence,
23622 event: NodeEvent::TerminalFrame {
23623 address,
23624 frame: terminal_frame(frame_sequence),
23625 },
23626 },
23627 0,
23628 );
23629 }
23630 }
23631 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23632 pending.sort_unstable_by_key(|event| event.sequence);
23633
23634 let mut uninterrupted = pending.clone();
23635 let first_burst = connection_event_burst_len(uninterrupted.len());
23636 uninterrupted.drain(..first_burst);
23637 let second_burst = connection_event_burst_len(uninterrupted.len());
23638 uninterrupted.drain(..second_burst);
23639 assert!(uninterrupted.is_empty());
23640
23641 let (frame_tx, mut frame_rx) =
23642 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23643 frame_tx
23644 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23645 request_id: 77,
23646 request: NodeRequest::Snapshot,
23647 })))
23648 .unwrap();
23649 let burst_len = connection_event_burst_len(pending.len());
23650 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23651 pending.drain(..burst_len);
23652 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23653 assert!(matches!(
23654 frame_rx.try_recv(),
23655 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23656 request_id: 77,
23657 request: NodeRequest::Snapshot,
23658 })))
23659 ));
23660 }
23661
23662 #[test]
23663 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23664 let mut pending = Vec::new();
23665 for sequence in [7_u64, 8] {
23666 let mut address = terminal_address(1);
23667 address.session.instance_id = AgentInstanceId(sequence);
23668 queue_connection_event(
23669 &mut pending,
23670 NodeEventEnvelope {
23671 sequence,
23672 event: NodeEvent::TerminalFrame {
23673 address,
23674 frame: terminal_frame(sequence),
23675 },
23676 },
23677 0,
23678 );
23679 }
23680 let mut discard_through = 0;
23681 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23682 assert!(pending.is_empty());
23683
23684 let replayed = NodeEventEnvelope {
23685 sequence: 8,
23686 event: NodeEvent::TerminalFrame {
23687 address: terminal_address(1),
23688 frame: terminal_frame(8),
23689 },
23690 };
23691 let advanced = NodeEventEnvelope {
23692 sequence: 9,
23693 event: NodeEvent::TerminalFrame {
23694 address: terminal_address(2),
23695 frame: terminal_frame(1),
23696 },
23697 };
23698 queue_connection_event_batch(
23699 &mut pending,
23700 &[replayed, advanced.clone()],
23701 discard_through,
23702 );
23703 assert_eq!(pending, vec![advanced]);
23704 }
23705
23706 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23707 ManagedSessionRecord {
23708 record_id: SessionRecordId::new(record_id).unwrap(),
23709 display_name: format!("{provider} record"),
23710 provider: agent(provider),
23711 mode: SessionMode::Pty,
23712 state: ManagedSessionState::Unavailable,
23713 workspace_id: WorkspaceId::new("primary").unwrap(),
23714 canonical_root: opaque_windows_path(
23715 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23716 ),
23717 provider_session: None,
23718 active_session: None,
23719 environment_profile: None,
23720 bundle: None,
23721 context_id: None,
23722 context: None,
23723 exported_context: None,
23724 task_binding: None,
23725 created_at_unix_ms: 1,
23726 updated_at_unix_ms: 1,
23727 last_error: None,
23728 }
23729 }
23730
23731 fn retention_test_record(
23732 workspace_id: &str,
23733 record_id: &str,
23734 state: ManagedSessionState,
23735 updated_at_unix_ms: u64,
23736 ) -> ManagedSessionRecord {
23737 ManagedSessionRecord {
23738 record_id: SessionRecordId::new(record_id).unwrap(),
23739 display_name: format!("{record_id} display"),
23740 provider: agent("claude"),
23741 mode: SessionMode::Pty,
23742 state,
23743 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23744 canonical_root: opaque_windows_path(
23745 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23746 ),
23747 provider_session: None,
23748 active_session: None,
23749 environment_profile: None,
23750 bundle: None,
23751 context_id: None,
23752 context: None,
23753 exported_context: None,
23754 task_binding: None,
23755 created_at_unix_ms: updated_at_unix_ms,
23756 updated_at_unix_ms,
23757 last_error: None,
23758 }
23759 }
23760
23761 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23769 vec![
23770 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23771 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23772 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23773 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23774 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23775 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23776 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23777 retention_test_record(
23778 "workspace-a",
23779 "decoy-pending",
23780 ManagedSessionState::IdentityPending,
23781 now - 500_000,
23782 ),
23783 retention_test_record(
23784 "workspace-a",
23785 "decoy-dormant",
23786 ManagedSessionState::Dormant,
23787 now - 500_000,
23788 ),
23789 ]
23790 }
23791
23792 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23793 let root = temporary_workspace_root("session-record-retention");
23794 std::fs::create_dir_all(&root).unwrap();
23795 let catalog = active_registry().unwrap();
23796 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23797 let workspace_a =
23798 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23799 let workspace_b =
23800 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23801 let mut shared = NodeShared::new(
23802 handle,
23803 "fixture-token".to_owned(),
23804 NodeId::new("node-retention-test").unwrap(),
23805 vec![workspace_a, workspace_b],
23806 vec![agent("claude")],
23807 );
23808 shared.state_path = Some(root.join("node-state-v10.json"));
23809 let now = unix_time_ms();
23810 for record in retention_fixture_records(now) {
23811 shared.insert_record(record).unwrap();
23812 }
23813 shared
23814 .persist_state_locked()
23815 .expect("fixture-only, empty-history persist against a fresh temp state path");
23816 (shared, runtime, root, now)
23817 }
23818
23819 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23820 shared
23821 .history
23822 .lock()
23823 .unwrap_or_else(|poisoned| poisoned.into_inner())
23824 .events
23825 .iter()
23826 .filter_map(|envelope| match &envelope.event {
23827 NodeEvent::SessionRecordRemoved { record_id } => {
23828 Some(record_id.as_str().to_owned())
23829 }
23830 _ => None,
23831 })
23832 .collect()
23833 }
23834
23835 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23836 shared
23837 .session_records
23838 .lock()
23839 .unwrap_or_else(|poisoned| poisoned.into_inner())
23840 .records
23841 .keys()
23842 .map(|record_id| record_id.as_str().to_owned())
23843 .collect()
23844 }
23845
23846 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23847 values.iter().map(|value| (*value).to_owned()).collect()
23848 }
23849
23850 #[tokio::test]
23851 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23852 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23853 shared.session_record_retention = SessionRecordRetentionConfig {
23854 age_ms: 0,
23855 keep_per_workspace: 1,
23856 };
23857 shared.retire_unavailable_session_records().await;
23858
23859 assert_eq!(
23860 retention_remaining_record_ids(&shared),
23861 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23862 );
23863 assert_eq!(
23864 retention_removed_record_ids(&shared),
23865 owned(&["a1", "a2", "b1", "b2"]),
23866 );
23867 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23868
23869 drop(shared);
23870 drop(runtime);
23871 std::fs::remove_dir_all(root).unwrap();
23872 }
23873
23874 #[tokio::test]
23880 async fn session_record_retention_both_set_retires_only_the_intersection() {
23881 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23882 shared.session_record_retention = SessionRecordRetentionConfig {
23883 age_ms: 250_000,
23884 keep_per_workspace: 1,
23885 };
23886 shared.retire_unavailable_session_records().await;
23887
23888 assert_eq!(
23889 retention_remaining_record_ids(&shared),
23890 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23891 );
23892 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23893 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23894
23895 drop(shared);
23896 drop(runtime);
23897 std::fs::remove_dir_all(root).unwrap();
23898 }
23899
23900 #[tokio::test]
23901 async fn session_record_retention_both_disabled_is_a_no_op() {
23902 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23903 shared.session_record_retention = SessionRecordRetentionConfig::default();
23904 shared.retire_unavailable_session_records().await;
23905
23906 assert_eq!(
23907 retention_remaining_record_ids(&shared),
23908 owned(&[
23909 "a1", "a2", "a3", "b1", "b2", "b3",
23910 "decoy-live", "decoy-pending", "decoy-dormant",
23911 ]),
23912 );
23913 assert!(retention_removed_record_ids(&shared).is_empty());
23914 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23915
23916 drop(shared);
23917 drop(runtime);
23918 std::fs::remove_dir_all(root).unwrap();
23919 }
23920
23921 #[test]
23922 fn session_record_context_export_selects_one_exact_history_identity() {
23923 let candidates = vec![
23924 HistoryCandidateSummary {
23925 id: "candidate-other".to_owned(),
23926 session_id_hint: "provider-other".to_owned(),
23927 modified_at_unix_ms: Some(1),
23928 },
23929 HistoryCandidateSummary {
23930 id: "candidate-exact".to_owned(),
23931 session_id_hint: "provider-exact".to_owned(),
23932 modified_at_unix_ms: Some(2),
23933 },
23934 ];
23935 assert_eq!(
23936 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23937 "candidate-exact",
23938 );
23939 }
23940
23941 #[test]
23942 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23943 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23944 id: id.to_owned(),
23945 session_id_hint: hint.to_owned(),
23946 modified_at_unix_ms: None,
23947 };
23948 let missing = select_history_candidate(
23949 &[candidate("candidate-other", "provider-other")],
23950 Some("provider-exact"),
23951 )
23952 .unwrap_err();
23953 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23954 let ambiguous = select_history_candidate(
23955 &[
23956 candidate("candidate-a", "provider-exact"),
23957 candidate("candidate-b", "provider-exact"),
23958 ],
23959 Some("provider-exact"),
23960 )
23961 .unwrap_err();
23962 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23963 }
23964
23965 #[test]
23973 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23974 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23975 id: id.to_owned(),
23976 session_id_hint: hint.to_owned(),
23977 modified_at_unix_ms: None,
23978 };
23979 assert_eq!(
23980 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23981 .unwrap(),
23982 "candidate-sole",
23983 );
23984 let missing = select_history_candidate(&[], None).unwrap_err();
23985 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23986 let ambiguous = select_history_candidate(
23987 &[
23988 candidate("candidate-a", "sidecar-session-a"),
23989 candidate("candidate-b", "sidecar-session-b"),
23990 ],
23991 None,
23992 )
23993 .unwrap_err();
23994 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23995 }
23996
23997 #[test]
23998 fn session_record_context_export_revalidation_rejects_record_change() {
23999 let session = terminal_address(1);
24000 let identity = ProviderSessionIdentity {
24001 key: ProviderSessionKey::SessionId,
24002 id: "provider-exact".to_owned(),
24003 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24004 };
24005 let mut expected = record("claude", "record-context-export");
24006 expected.state = ManagedSessionState::Live;
24007 expected.provider_session = Some(identity.clone());
24008 expected.active_session = Some(session.clone());
24009 let binding = SessionBinding {
24010 workspace_id: session.workspace_id.clone(),
24011 generation: session.session.generation,
24012 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24013 pending_resume: None,
24014 record_id: Some(expected.record_id.clone()),
24015 managed_worktree_lease_id: None,
24016 environment_profile: None,
24017 bundle: None,
24018 context: None,
24019 materialization_id: None,
24020 };
24021 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24022 assert!(session_record_context_export_binding_is_exact(
24023 &expected,
24024 &expected,
24025 Some(&identity),
24026 &session,
24027 &binding,
24028 &expected.provider,
24029 ¤t_root,
24030 false,
24031 ));
24032 let mut changed = expected.clone();
24033 changed.updated_at_unix_ms += 1;
24034 assert!(!session_record_context_export_binding_is_exact(
24035 &expected,
24036 &changed,
24037 Some(&identity),
24038 &session,
24039 &binding,
24040 &expected.provider,
24041 ¤t_root,
24042 false,
24043 ));
24044 assert!(!session_record_context_export_binding_is_exact(
24049 &expected,
24050 &changed,
24051 Some(&identity),
24052 &session,
24053 &binding,
24054 &expected.provider,
24055 ¤t_root,
24056 true,
24057 ));
24058 let mut detached = expected.clone();
24059 detached.state = ManagedSessionState::Dormant;
24060 detached.active_session = None;
24061 detached.updated_at_unix_ms += 1;
24062 assert!(!session_record_context_export_binding_is_exact(
24063 &expected,
24064 &detached,
24065 Some(&identity),
24066 &session,
24067 &binding,
24068 &expected.provider,
24069 ¤t_root,
24070 false,
24071 ));
24072 assert!(session_record_context_export_binding_is_exact(
24073 &expected,
24074 &detached,
24075 Some(&identity),
24076 &session,
24077 &binding,
24078 &expected.provider,
24079 ¤t_root,
24080 true,
24081 ));
24082 }
24083
24084 #[test]
24091 fn session_record_context_export_binding_is_exact_without_identity() {
24092 let session = terminal_address(1);
24093 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24094 expected.state = ManagedSessionState::Live;
24095 expected.active_session = Some(session.clone());
24096 let binding = SessionBinding {
24097 workspace_id: session.workspace_id.clone(),
24098 generation: session.session.generation,
24099 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24100 pending_resume: None,
24101 record_id: Some(expected.record_id.clone()),
24102 managed_worktree_lease_id: None,
24103 environment_profile: None,
24104 bundle: None,
24105 context: None,
24106 materialization_id: None,
24107 };
24108 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24109 assert!(session_record_context_export_binding_is_exact(
24110 &expected,
24111 &expected,
24112 None,
24113 &session,
24114 &binding,
24115 &expected.provider,
24116 ¤t_root,
24117 false,
24118 ));
24119 let mut drifted = expected.clone();
24123 drifted.provider_session = Some(ProviderSessionIdentity {
24124 key: ProviderSessionKey::SessionId,
24125 id: "unexpected-identity".to_owned(),
24126 transcript_path: None,
24127 });
24128 assert!(!session_record_context_export_binding_is_exact(
24129 &expected,
24130 &drifted,
24131 None,
24132 &session,
24133 &binding,
24134 &expected.provider,
24135 ¤t_root,
24136 false,
24137 ));
24138 }
24139
24140 #[test]
24141 fn session_record_context_export_preflight_requires_live_usable_source() {
24142 let mut source = record("claude", "record-context-export-preflight");
24143 source.state = ManagedSessionState::Live;
24144 assert!(session_record_context_export_source_is_usable(
24145 &source,
24146 &SessionStatus::Running,
24147 false,
24148 ));
24149 assert!(session_record_context_export_source_is_usable(
24150 &source,
24151 &SessionStatus::Exited { exit_code: Some(0) },
24152 false,
24153 ));
24154
24155 source.state = ManagedSessionState::Dormant;
24156 assert!(!session_record_context_export_source_is_usable(
24157 &source,
24158 &SessionStatus::Running,
24159 false,
24160 ));
24161 assert!(session_record_context_export_source_is_usable(
24165 &source,
24166 &SessionStatus::Exited { exit_code: Some(0) },
24167 true,
24168 ));
24169 assert!(!session_record_context_export_source_is_usable(
24170 &source,
24171 &SessionStatus::Exited { exit_code: Some(0) },
24172 false,
24173 ));
24174 source.state = ManagedSessionState::Live;
24175 assert!(!session_record_context_export_source_is_usable(
24176 &source,
24177 &SessionStatus::Starting,
24178 false,
24179 ));
24180 }
24181
24182 #[test]
24183 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24184 let catalog = active_registry().unwrap();
24185 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24186 let workspace = WorkspaceConfig::new(
24187 WorkspaceId::new("primary").unwrap(),
24188 std::env::current_dir().unwrap(),
24189 )
24190 .unwrap();
24191 let shared = NodeShared::new(
24192 handle,
24193 "fixture-token".to_owned(),
24194 NodeId::new("node-context-export-conflict").unwrap(),
24195 vec![workspace],
24196 vec![agent("claude")],
24197 );
24198 let history = HistorySessionRecord {
24199 session_id: "provider-exact".to_owned(),
24200 title: None,
24201 cwd: None,
24202 model: None,
24203 message_count: 1,
24204 completed_turn_count: None,
24205 total_tokens: 0,
24206 messages: vec![gate4agent_types::HistoryMessageRecord {
24207 role: gate4agent_types::HistoryMessageRole::User,
24208 text: "bounded context".to_owned(),
24209 }],
24210 };
24211 let pack = NodeContextPack::export(
24212 ContextPackLineageReceipt {
24213 source_node_id: shared.node_id.clone(),
24214 source_session: terminal_address(1),
24215 source_provider: agent("claude"),
24216 },
24217 &history,
24218 )
24219 .unwrap();
24220 let context_id = pack.receipt().id.clone();
24221 let session = terminal_address(1);
24222 let identity = ProviderSessionIdentity {
24223 key: ProviderSessionKey::SessionId,
24224 id: history.session_id.clone(),
24225 transcript_path: None,
24226 };
24227 let mut expected = record("claude", "record-context-export-final-conflict");
24228 expected.state = ManagedSessionState::Live;
24229 expected.provider_session = Some(identity.clone());
24230 expected.active_session = Some(session.clone());
24231 let mut changed = expected.clone();
24232 changed.updated_at_unix_ms += 1;
24233 shared.insert_record(changed).unwrap();
24234
24235 let error = shared
24236 .commit_context_pack_for_session_record(
24237 &expected,
24238 Some(&identity),
24239 &session,
24240 "candidate-exact",
24241 &history,
24242 pack,
24243 false,
24244 )
24245 .unwrap_err();
24246
24247 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24248 assert!(shared
24249 .context_catalog
24250 .read()
24251 .unwrap_or_else(|poisoned| poisoned.into_inner())
24252 .get(&context_id)
24253 .is_none());
24254 }
24255
24256 #[tokio::test]
24257 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24258 let root = temporary_workspace_root("session-task-binding");
24259 std::fs::create_dir_all(&root).unwrap();
24260 let state_path = root.join("node-state.json");
24261 let catalog = active_registry().unwrap();
24262 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24263 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24264 let node_id = NodeId::new("node-1").unwrap();
24265 let mut shared = NodeShared::new(
24266 handle,
24267 "fixture-token".to_owned(),
24268 node_id.clone(),
24269 vec![workspace],
24270 vec![agent("claude")],
24271 );
24272 shared.state_path = Some(state_path.clone());
24273 let record = record("claude", "sr-task");
24274 let record_id = record.record_id.clone();
24275 shared.insert_record(record).unwrap();
24276 shared.persist_state().unwrap();
24277 shared
24278 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24279 .unwrap();
24280
24281 let minted = process_request_inner(
24282 &shared,
24283 77,
24284 ClientRole::Operator,
24285 NodeRequest::SetSessionTask {
24286 record_id: record_id.clone(),
24287 expected_revision: 0,
24288 target: SessionTaskTargetV1::New,
24289 },
24290 )
24291 .await
24292 .unwrap();
24293 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24294 panic!("session task mutation returned another response");
24295 };
24296 let minted_binding = minted.task_binding.clone().unwrap();
24297 let minted_task_id = minted_binding.task_id.clone().unwrap();
24298 assert_eq!(minted_binding.revision, 1);
24299 assert!(minted_task_id.as_str().starts_with("task-"));
24300 assert_eq!(minted_task_id.as_str().len(), 29);
24301
24302 let event_count = shared.current_sequence();
24303 let repeated = process_request_inner(
24304 &shared,
24305 77,
24306 ClientRole::Operator,
24307 NodeRequest::SetSessionTask {
24308 record_id: record_id.clone(),
24309 expected_revision: 1,
24310 target: SessionTaskTargetV1::Existing {
24311 task_id: minted_task_id.clone(),
24312 },
24313 },
24314 )
24315 .await
24316 .unwrap();
24317 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24318 assert_eq!(shared.current_sequence(), event_count);
24319
24320 let stale = process_request_inner(
24321 &shared,
24322 77,
24323 ClientRole::Operator,
24324 NodeRequest::SetSessionTask {
24325 record_id: record_id.clone(),
24326 expected_revision: 0,
24327 target: SessionTaskTargetV1::Existing {
24328 task_id: minted_task_id,
24329 },
24330 },
24331 )
24332 .await
24333 .unwrap_err();
24334 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24335
24336 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24337 let assigned = process_request_inner(
24338 &shared,
24339 77,
24340 ClientRole::Operator,
24341 NodeRequest::SetSessionTask {
24342 record_id: record_id.clone(),
24343 expected_revision: 1,
24344 target: SessionTaskTargetV1::Existing {
24345 task_id: assigned_id.clone(),
24346 },
24347 },
24348 )
24349 .await
24350 .unwrap();
24351 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24352 panic!("session task assignment returned another response");
24353 };
24354 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24355 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24356
24357 let cleared = process_request_inner(
24358 &shared,
24359 77,
24360 ClientRole::Operator,
24361 NodeRequest::SetSessionTask {
24362 record_id: record_id.clone(),
24363 expected_revision: 2,
24364 target: SessionTaskTargetV1::Clear,
24365 },
24366 )
24367 .await
24368 .unwrap();
24369 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24370 panic!("session task clear returned another response");
24371 };
24372 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24373 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24374 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24375 assert_eq!(
24376 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24377 cleared.updated_at_unix_ms,
24378 );
24379
24380 let persisted = std::fs::read(&state_path).unwrap();
24381 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24382 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24383 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24384 std::fs::remove_dir_all(root).unwrap();
24385 }
24386
24387 #[test]
24388 fn session_task_capability_strips_snapshots_events_and_replies() {
24389 let mut bound = record("claude", "sr-task-projection");
24390 bound.updated_at_unix_ms = 2;
24391 bound.task_binding = Some(SessionTaskBindingV1 {
24392 revision: 1,
24393 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24394 changed_at_unix_ms: 2,
24395 });
24396 let request = NodeRequest::SetSessionTask {
24397 record_id: bound.record_id.clone(),
24398 expected_revision: 1,
24399 target: SessionTaskTargetV1::Clear,
24400 };
24401 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24402 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24403 }));
24404 assert!(!request_uses_unnegotiated_capability(
24405 &request,
24406 &baseline_capabilities().unwrap(),
24407 ));
24408 assert!(request_uses_unnegotiated_capability(&request, &[]));
24409
24410 let mut snapshot = terminal_test_shared().snapshot();
24411 snapshot.session_records.push(bound.clone());
24412 let mut reply = ResponseEnvelope {
24413 request_id: 1,
24414 result: Ok(NodeResponse::Resync {
24415 event_sequence: 1,
24416 oldest_available_sequence: 1,
24417 snapshot,
24418 events: vec![NodeEventEnvelope {
24419 sequence: 1,
24420 event: NodeEvent::SessionRecordUpserted {
24421 record: bound.clone(),
24422 },
24423 }],
24424 }),
24425 };
24426 project_response_without_session_task_binding(&mut reply);
24427 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24428 panic!("task projection changed the response kind");
24429 };
24430 assert!(snapshot.session_records[0].task_binding.is_none());
24431 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24432 panic!("task projection changed the event kind");
24433 };
24434 assert!(record.task_binding.is_none());
24435
24436 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24437 sequence: 2,
24438 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24439 });
24440 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24441 panic!("task event projection changed the event kind");
24442 };
24443 assert!(record.task_binding.is_none());
24444
24445 let mut direct = ResponseEnvelope {
24446 request_id: 2,
24447 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24448 };
24449 project_response_without_session_task_binding(&mut direct);
24450 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24451 panic!("task reply projection changed the response kind");
24452 };
24453 assert!(record.task_binding.is_none());
24454 }
24455
24456 #[test]
24457 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24458 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24459 let workspace = WorkspaceConfig::new(
24460 WorkspaceId::new("primary").unwrap(),
24461 std::env::current_dir().unwrap(),
24462 )
24463 .unwrap();
24464 let config = NodeServerConfig::new(
24465 DEFAULT_NODE_ENDPOINT,
24466 "fixture-token",
24467 NodeId::new("node-1").unwrap(),
24468 [workspace],
24469 )
24470 .unwrap();
24471 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24472 }
24473
24474 #[cfg(all(feature = "fixture", windows))]
24475 #[test]
24476 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24477 let root = temporary_workspace_root("provider-bundle-argv-hold");
24478 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24479 std::fs::create_dir_all(&root).unwrap();
24480 std::fs::create_dir_all(&outside).unwrap();
24481 let proof_path = root.join("provider.proof");
24482 let release_signal = root.join("release.signal");
24483 let config = NodeServerConfig::new(
24484 format!(
24485 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24486 std::process::id(),
24487 ),
24488 "fixture-token",
24489 NodeId::new("provider-bundle-hold-node").unwrap(),
24490 [WorkspaceConfig::new(
24491 WorkspaceId::new("primary").unwrap(),
24492 std::env::current_dir().unwrap(),
24493 )
24494 .unwrap()],
24495 )
24496 .unwrap()
24497 .with_spawn_profiles(SpawnProfileRegistry::new([
24498 crate::protocol::SpawnProfileDefaults {
24499 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24500 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24501 .unwrap(),
24502 provider: agent("kimi"),
24503 mode: SessionMode::Pty,
24504 terminal_size: gate4agent_types::TerminalSize {
24505 rows: 24,
24506 columns: 80,
24507 },
24508 prompt: None,
24509 bundle_id: None,
24510 context_id: None,
24511 environment_profile_id: None,
24512 },
24513 ])
24514 .unwrap());
24515 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24516 config,
24517 agent("kimi"),
24518 proof_path.clone(),
24519 release_signal.clone(),
24520 )
24521 .unwrap();
24522 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24523
24524 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24525 agent("kimi"),
24526 proof_path.clone(),
24527 Some(release_signal.clone()),
24528 )
24529 .unwrap();
24530 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24531 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24532 let release_wait = wrapper
24533 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24534 .unwrap();
24535 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24536 assert!(proof_write < release_wait);
24537 assert!(release_wait < fixture_ready);
24538 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24539 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24540 assert_eq!(
24541 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24542 &[
24543 proof_path.to_string_lossy().into_owned(),
24544 release_signal.to_string_lossy().into_owned(),
24545 ],
24546 );
24547
24548 let error = NodeServer::provider_bundle_argv_fixture_spec(
24549 agent("kimi"),
24550 proof_path,
24551 Some(outside.join("release.signal")),
24552 )
24553 .unwrap_err();
24554 assert!(matches!(
24555 error,
24556 NodeServerError::Registry(message)
24557 if message == "provider bundle argv release path must share the proof parent"
24558 ));
24559 drop(server);
24560 std::fs::remove_dir_all(root).unwrap();
24561 std::fs::remove_dir_all(outside).unwrap();
24562 }
24563
24564 #[cfg(feature = "fixture")]
24565 #[test]
24566 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24567 let root = temporary_workspace_root("context-fixture-registry");
24568 std::fs::create_dir_all(&root).unwrap();
24569 let proof_path = root.join("context.proof");
24570 let config = NodeServerConfig::new(
24571 format!(
24572 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24573 std::process::id(),
24574 ),
24575 "fixture-token",
24576 NodeId::new("context-fixture-node").unwrap(),
24577 [WorkspaceConfig::new(
24578 WorkspaceId::new("primary").unwrap(),
24579 std::env::current_dir().unwrap(),
24580 )
24581 .unwrap()],
24582 )
24583 .unwrap();
24584 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24585 let mut enabled = server
24586 .shared
24587 .enabled_providers
24588 .iter()
24589 .map(AgentId::as_str)
24590 .collect::<Vec<_>>();
24591 enabled.sort_unstable();
24592 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24593 for provider in &server.shared.enabled_providers {
24594 assert!(server
24595 .shared
24596 .provider_adapter_contracts
24597 .iter()
24598 .any(|contract| {
24599 &contract.provider == provider
24600 && contract.family == AdapterFamily::History
24601 }));
24602 }
24603 drop(server);
24604 std::fs::remove_dir_all(root).unwrap();
24605 }
24606
24607 #[cfg(feature = "fixture")]
24608 #[test]
24609 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24610 let root = temporary_workspace_root("monitoring-context-fixture");
24611 std::fs::create_dir_all(&root).unwrap();
24612 let proof_path = root.join("context.proof");
24613 let standard = NodeServer::context_pack_fixture_catalog(
24614 proof_path.clone(),
24615 None,
24616 false,
24617 ).unwrap();
24618 let monitoring = NodeServer::context_pack_fixture_catalog(
24619 proof_path.clone(),
24620 None,
24621 true,
24622 ).unwrap();
24623 let claude = AgentId::new("claude").unwrap();
24624 let standard_claude = standard.get(&claude).unwrap();
24625 let monitoring_claude = monitoring.get(&claude).unwrap();
24626 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24630 assert_eq!(
24631 monitoring_claude.capabilities.adapters.history,
24632 standard_claude.capabilities.adapters.history,
24633 );
24634 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24635 let codex = AgentId::new("codex").unwrap();
24636 let standard_codex = standard.get(&codex).unwrap();
24637 let monitoring_codex = monitoring.get(&codex).unwrap();
24638 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24639 assert_eq!(
24640 monitoring_codex.capabilities.adapters.history,
24641 standard_codex.capabilities.adapters.history,
24642 );
24643 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24644 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24645 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24646 .unwrap();
24647 for required in [
24648 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24649 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24650 "@('role', 'text')",
24651 "g4a-private-provider-session-canary",
24652 "private-provider-login-identity",
24653 "$rawProof",
24654 "$env:GATE4AGENT_HOOK_URL",
24655 "g4a-private-provider-session-canary",
24656 ] {
24657 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24658 }
24659 for provider in ["grok", "kimi"] {
24660 let provider = AgentId::new(provider).unwrap();
24661 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24662 }
24663
24664 let config = NodeServerConfig::new(
24665 format!(
24666 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24667 std::process::id(),
24668 ),
24669 "fixture-token",
24670 NodeId::new("monitoring-context-fixture-node").unwrap(),
24671 [WorkspaceConfig::new(
24672 WorkspaceId::new("primary").unwrap(),
24673 std::env::current_dir().unwrap(),
24674 )
24675 .unwrap()],
24676 )
24677 .unwrap();
24678 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24679 assert!(server.shared.fixture_semantic_hook_policy);
24680 drop(server);
24681 std::fs::remove_dir_all(root).unwrap();
24682 }
24683
24684 #[cfg(feature = "fixture")]
24685 #[test]
24686 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24687 let root = temporary_workspace_root("kimi-context-only-fixture");
24688 std::fs::create_dir_all(&root).unwrap();
24689 let proof_path = root.join("context.proof");
24690 let standard = NodeServer::context_pack_fixture_catalog(
24691 proof_path.clone(),
24692 None,
24693 false,
24694 ).unwrap();
24695 let kimi = AgentId::new("kimi").unwrap();
24696 let context_only = NodeServer::context_pack_fixture_catalog(
24697 proof_path,
24698 Some(&kimi),
24699 false,
24700 )
24701 .unwrap();
24702
24703 let codex = AgentId::new("codex").unwrap();
24704 assert_eq!(
24705 standard.get(&codex).unwrap().launch.fixed_args,
24706 context_only.get(&codex).unwrap().launch.fixed_args,
24707 );
24708 let script = context_only
24709 .get(&kimi)
24710 .unwrap()
24711 .launch
24712 .fixed_args
24713 .iter()
24714 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24715 .expect("Kimi context-only validation script is missing");
24716 for required in [
24717 "$bundleArgs.Count -ne 0",
24718 "GATE4AGENT_CONTEXT_ROOT",
24719 "context-pack.json",
24720 "$entries.Count -ne 1",
24721 "g4a-context-pack-v1",
24722 "$document.PSObject.Properties['cwd']",
24723 "'claude', 'codex', 'grok', 'kimi'",
24724 "$hasUser",
24725 "$hasAssistant",
24726 "SHA256",
24727 "context-only",
24728 ] {
24729 assert!(script.contains(required), "missing validation: {required}");
24730 }
24731 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24732 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24733 }
24734 assert!(!standard
24735 .get(&kimi)
24736 .unwrap()
24737 .launch
24738 .fixed_args
24739 .iter()
24740 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24741
24742 std::fs::remove_dir_all(root).unwrap();
24743 }
24744
24745 #[cfg(all(feature = "fixture", windows))]
24746 #[tokio::test]
24747 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24748 let root = temporary_workspace_root("kimi-context-only-spawn");
24749 let workspace_root = root.join("workspace");
24750 std::fs::create_dir_all(&workspace_root).unwrap();
24751 let materialization_root = root.join("private-materializations");
24752 let proof_path = root.join("context.proof");
24753 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24754 let workspace_id = WorkspaceId::new("primary").unwrap();
24755 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24756 let endpoint = format!(
24757 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24758 std::process::id(),
24759 unix_time_ms(),
24760 );
24761 let spawn_profiles = SpawnProfileRegistry::new([
24762 crate::protocol::SpawnProfileDefaults {
24763 profile_id: profile_id.clone(),
24764 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24765 provider: agent("kimi"),
24766 mode: SessionMode::Pty,
24767 terminal_size: gate4agent_types::TerminalSize {
24768 rows: 30,
24769 columns: 120,
24770 },
24771 prompt: None,
24772 bundle_id: None,
24773 context_id: None,
24774 environment_profile_id: None,
24775 },
24776 ])
24777 .unwrap();
24778 let mut config = NodeServerConfig::new(
24779 &endpoint,
24780 "fixture-token",
24781 node_id.clone(),
24782 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24783 )
24784 .unwrap()
24785 .with_spawn_profiles(spawn_profiles)
24786 .with_session_environment_materialization(
24787 materialization_root,
24788 Arc::new(FixtureSecretResolver {
24789 deny: Arc::new(AtomicBool::new(false)),
24790 }),
24791 )
24792 .unwrap();
24793 config.fixture_raw_pty_runtime = true;
24794 let server = NodeServer::new_context_only_proof_fixture(
24795 config,
24796 agent("kimi"),
24797 proof_path.clone(),
24798 )
24799 .unwrap();
24800 let shared = Arc::clone(&server.shared);
24801 let shutdown = server.shutdown_handle();
24802
24803 let history = HistorySessionRecord {
24804 session_id: "context-only-source-history".to_owned(),
24805 title: None,
24806 cwd: None,
24807 model: None,
24808 message_count: 2,
24809 completed_turn_count: None,
24810 total_tokens: 0,
24811 messages: vec![
24812 gate4agent_types::HistoryMessageRecord {
24813 role: gate4agent_types::HistoryMessageRole::User,
24814 text: "continue the bounded parent run through native C2".to_owned(),
24815 },
24816 gate4agent_types::HistoryMessageRecord {
24817 role: gate4agent_types::HistoryMessageRole::Assistant,
24818 text: "the exact parent context is ready for the child run".to_owned(),
24819 },
24820 ],
24821 };
24822 let pack = NodeContextPack::export(
24823 ContextPackLineageReceipt {
24824 source_node_id: node_id.clone(),
24825 source_session: SessionAddress {
24826 workspace_id: workspace_id.clone(),
24827 session: SessionKey {
24828 instance_id: AgentInstanceId(71),
24829 generation: SessionGeneration(1),
24830 },
24831 },
24832 source_provider: agent("codex"),
24833 },
24834 &history,
24835 )
24836 .unwrap();
24837 let expected_context_hash = digest(&SHA256, pack.bytes())
24838 .as_ref()
24839 .iter()
24840 .map(|byte| format!("{byte:02x}"))
24841 .collect::<String>();
24842 let context = shared
24843 .context_catalog
24844 .write()
24845 .unwrap_or_else(|poisoned| poisoned.into_inner())
24846 .insert(pack)
24847 .unwrap();
24848 let spec = SpawnSpec {
24849 target: crate::protocol::SpawnTarget {
24850 node_id,
24851 workspace_id: workspace_id.clone(),
24852 worktree_id: None,
24853 },
24854 profile_id,
24855 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24856 "target-kimi-r1",
24857 ).unwrap(),
24858 overrides: crate::protocol::SpawnOverrides {
24859 context_id: crate::protocol::SpawnOverride::Set {
24860 value: context.id.clone(),
24861 },
24862 ..crate::protocol::SpawnOverrides::default()
24863 },
24864 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24865 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24866 required_capabilities: SpawnRequiredCapabilities::default(),
24867 };
24868 let resolved = match shared.resolve_spawn_spec(&spec) {
24869 Ok(resolved) => resolved,
24870 Err(error) => panic!(
24871 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24872 error.code,
24873 error.message,
24874 ),
24875 };
24876 let resolved_context = match shared.resolve_context(&resolved) {
24877 Ok(Some(resolved_context)) => resolved_context,
24878 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24879 Err(error) => panic!(
24880 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24881 error.code,
24882 error.message,
24883 ),
24884 };
24885 assert_eq!(resolved_context, context);
24886 let runtime_policy = match shared
24887 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24888 .await
24889 {
24890 Ok(runtime_policy) => runtime_policy,
24891 Err(error) => panic!(
24892 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24893 error.code,
24894 error.message,
24895 ),
24896 };
24897 assert!(runtime_policy.raw_pty_lifecycle);
24898
24899 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24900 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24901 agent("kimi"),
24902 TransportKind::Pty,
24903 vec![EnvMutation {
24904 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24905 value: Some(OsString::from("must-remain-rejected")),
24906 }],
24907 )
24908 .unwrap();
24909 assert_eq!(
24910 launch_control
24911 .install_native_launch_environment_overlay(
24912 AgentInstanceId(72),
24913 generic_overlay,
24914 )
24915 .unwrap_err(),
24916 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24917 );
24918
24919 let diagnostic_address = SessionAddress {
24920 workspace_id: workspace_id.clone(),
24921 session: SessionKey {
24922 instance_id: AgentInstanceId(73),
24923 generation: SessionGeneration(1),
24924 },
24925 };
24926 let (diagnostic_overlay, diagnostic_materialization) = match shared
24927 .prepare_session_materialization(
24928 &diagnostic_address,
24929 &agent("kimi"),
24930 SessionMode::Pty,
24931 None,
24932 None,
24933 Some(&resolved_context),
24934 None,
24935 )
24936 {
24937 Ok(Some(prepared)) => prepared,
24938 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24939 Err(error) => panic!(
24940 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24941 error.code,
24942 error.message,
24943 ),
24944 };
24945 let diagnostic_overlay = diagnostic_overlay
24946 .expect("Kimi context-only materialization omitted its environment overlay");
24947 assert!(matches!(
24948 &diagnostic_overlay,
24949 PreparedNativeLaunchOverlay::Environment(_),
24950 ));
24951 match shared.install_prepared_launch_overlay(
24952 diagnostic_address.session.instance_id,
24953 diagnostic_overlay,
24954 ) {
24955 Ok(None) => {}
24956 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24957 Err(error) => panic!(
24958 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24959 error.code,
24960 error.message,
24961 ),
24962 }
24963 assert!(launch_control.clear_native_instance_launch_overlay(
24964 diagnostic_address.session.instance_id,
24965 ));
24966 drop(diagnostic_materialization);
24967 assert!(shared
24968 .materializations
24969 .lock()
24970 .unwrap_or_else(|poisoned| poisoned.into_inner())
24971 .is_empty());
24972
24973 let node_task = tokio::spawn(server.run());
24974 let ready_client = timeout(Duration::from_secs(5), async {
24975 loop {
24976 match gate4agent_node_wire::LocalNodeClient::connect(
24977 &endpoint,
24978 &shared.node_id,
24979 ClientRole::Observer,
24980 "fixture-token",
24981 )
24982 .await
24983 {
24984 Ok(client) => break client,
24985 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24986 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24987 }
24988 }
24989 })
24990 .await
24991 .expect("Kimi context-only Node did not become ready");
24992 assert_eq!(
24993 ready_client
24994 .hello()
24995 .snapshot
24996 .provider_runtime_statuses
24997 .iter()
24998 .find(|status| status.provider().as_str() == "kimi")
24999 .expect("Kimi runtime status is missing")
25000 .mode(),
25001 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25002 );
25003 drop(ready_client);
25004
25005 let receipt = match shared.spawn_from_spec(spec).await {
25006 Ok(receipt) => receipt,
25007 Err(error) => panic!(
25008 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25009 error.code,
25010 error.message,
25011 ),
25012 };
25013 assert_eq!(receipt.provider, agent("kimi"));
25014 assert_eq!(receipt.context.as_ref(), Some(&context));
25015 assert!(receipt.context_binding_is_valid());
25016 timeout(Duration::from_secs(5), async {
25017 while !proof_path.is_file() {
25018 sleep(Duration::from_millis(10)).await;
25019 }
25020 })
25021 .await
25022 .expect("Kimi context-only fixture did not write its proof");
25023
25024 let proof = std::fs::read_to_string(&proof_path).unwrap();
25025 let lines = proof.lines().collect::<Vec<_>>();
25026 assert_eq!(lines.len(), 7);
25027 assert_eq!(lines[0], "context-only");
25028 assert!(Path::new(lines[1]).is_absolute());
25029 assert_ne!(
25030 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25031 std::fs::canonicalize(&workspace_root).unwrap(),
25032 );
25033 assert_eq!(
25034 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25035 std::fs::canonicalize(&workspace_root).unwrap(),
25036 );
25037 assert_eq!(lines[3], expected_context_hash);
25038 assert_eq!(lines[4], "g4a-context-pack-v1");
25039 assert_eq!(lines[5], "codex");
25040 assert_eq!(lines[6], "2");
25041 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25042 session.instance_id == receipt.session.session.instance_id
25043 && session.agent_id.as_str() == "kimi"
25044 }));
25045
25046 shutdown.request_shutdown().await.unwrap();
25047 timeout(Duration::from_secs(5), node_task)
25048 .await
25049 .unwrap()
25050 .unwrap()
25051 .unwrap();
25052 drop(shutdown);
25053 drop(shared);
25054 std::fs::remove_dir_all(root).unwrap();
25055 }
25056
25057 #[cfg(feature = "fixture")]
25058 #[test]
25059 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25060 let root = temporary_workspace_root("clean-exit-constructor");
25061 std::fs::create_dir_all(&root).unwrap();
25062 let config = construction_test_config("clean-exit-constructor")
25063 .with_state_path(root.join("node-state.json"))
25064 .unwrap();
25065 let server = NodeServer::new_clean_exit_fixture(
25066 config,
25067 root.clone(),
25068 root.join("started.marker"),
25069 root.join("release.signal"),
25070 )
25071 .unwrap();
25072 assert_eq!(
25073 server
25074 .shared
25075 .enabled_providers
25076 .iter()
25077 .map(AgentId::as_str)
25078 .collect::<Vec<_>>(),
25079 ["claude"],
25080 );
25081 drop(server);
25082 std::fs::remove_dir_all(root).unwrap();
25083 }
25084
25085 #[test]
25086 fn production_registry_provider_contract_manifest_is_exact() {
25087 let registry = active_registry().unwrap();
25088 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25089 assert_eq!(
25090 providers
25091 .iter()
25092 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25093 .collect::<Vec<_>>(),
25094 vec![
25095 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25096 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25097 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25098 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25099 ]
25100 );
25101 assert_eq!(
25102 adapters
25103 .iter()
25104 .map(|contract| (
25105 contract.provider.as_str(),
25106 contract.family,
25107 contract.adapter_id.as_str(),
25108 contract.revision.as_str(),
25109 ))
25110 .collect::<Vec<_>>(),
25111 vec![
25112 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25113 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25114 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25115 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25116 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25117 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25118 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25119 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25120 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25121 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25122 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25123 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25124 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25125 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25126 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25127 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25128 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25129 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25130 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25131 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25132 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25133 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25134 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25135 ]
25136 );
25137 }
25138
25139 fn construction_test_config(name: &str) -> NodeServerConfig {
25140 let workspace = WorkspaceConfig::new(
25141 WorkspaceId::new("primary").unwrap(),
25142 std::env::current_dir().unwrap(),
25143 )
25144 .unwrap();
25145 NodeServerConfig::new(
25146 format!(r"\\.\pipe\gate4agent-{name}"),
25147 "fixture-token",
25148 NodeId::new(name).unwrap(),
25149 [workspace],
25150 )
25151 .unwrap()
25152 }
25153
25154 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25155 let adapter = AdapterBinding::new(
25156 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25157 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25158 gate4agent_types::AdapterVerification::SyntheticFixture,
25159 )
25160 .unwrap();
25161 let agent_ids = ["claude", "codex", "kimi"]
25162 .into_iter()
25163 .map(|agent_id| AgentId::new(agent_id).unwrap())
25164 .collect::<Vec<_>>();
25165 let adapters = gate4agent_catalog::AdapterRegistry::new(
25166 [
25167 AdapterFamily::PtySemantic,
25168 AdapterFamily::Pipe,
25169 AdapterFamily::OneShot,
25170 AdapterFamily::Acp,
25171 AdapterFamily::Hook,
25172 AdapterFamily::ManagedHook,
25173 AdapterFamily::History,
25174 AdapterFamily::Resume,
25175 AdapterFamily::SessionOptions,
25176 AdapterFamily::CapabilityProbe,
25177 ]
25178 .into_iter()
25179 .map(|family| gate4agent_catalog::AdapterDescriptor {
25180 family,
25181 binding: adapter.clone(),
25182 agents: agent_ids.clone(),
25183 }),
25184 )
25185 .unwrap();
25186 let specs = agent_ids
25187 .iter()
25188 .map(|agent_id| {
25189 let agent_id = agent_id.as_str();
25190 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25191 spec.revision =
25192 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25193 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25194 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25195 adapter: adapter.clone(),
25196 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25197 launch_override: None,
25198 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25199 });
25200 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25201 adapter: adapter.clone(),
25202 launch_override: None,
25203 });
25204 spec.capabilities.adapters.hook = Some(adapter.clone());
25205 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25206 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25207 spec.capabilities.adapters.history = Some(adapter.clone());
25208 spec.capabilities.adapters.resume = Some(adapter.clone());
25209 spec.capabilities.adapters.session_options = Some(adapter.clone());
25210 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25211 spec
25212 })
25213 .collect::<Vec<_>>();
25214 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25215 }
25216
25217 #[test]
25225 fn node_server_construction_accepts_the_production_manifest() {
25226 let registry = active_registry().unwrap();
25227 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25228 assert_eq!(providers.len(), registry.iter().count());
25229 assert!(!providers.is_empty());
25230 let server = NodeServer::new_with_registry(
25231 construction_test_config("manifest-production-bound"),
25232 registry,
25233 )
25234 .unwrap();
25235 drop(server);
25236 }
25237
25238 #[test]
25239 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25240 let registry = maximum_size_provider_manifest_registry();
25241 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25242 assert_eq!(providers.len(), 3);
25243 assert_eq!(adapters.len(), 30);
25244 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25245 let error = match NodeServer::new_with_registry(
25246 construction_test_config("manifest-handshake-overflow"),
25247 registry,
25248 ) {
25249 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25250 Err(error) => error,
25251 };
25252 assert!(matches!(
25253 error,
25254 NodeServerError::ProviderContractManifest(message)
25255 if message.contains("authentication binding length")
25256 && message.contains("8192-byte limit")
25257 ));
25258 }
25259
25260 #[test]
25261 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25262 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25263 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25264 let registry = AgentRegistry::new([spec]).unwrap();
25265 assert!(matches!(
25266 provider_contract_manifest(®istry),
25267 Err(NodeServerError::ProviderContractManifest(message))
25268 if message.contains("exceeds the 128-byte limit")
25269 ));
25270 }
25271
25272 #[test]
25273 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25274 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25275 spec.revision = "invalid revision".to_owned();
25276 let registry = AgentRegistry::new([spec]).unwrap();
25277 assert!(matches!(
25278 provider_contract_manifest(®istry),
25279 Err(NodeServerError::ProviderContractManifest(message))
25280 if message.contains("bounded lowercase ASCII")
25281 ));
25282 }
25283
25284 #[test]
25285 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25286 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25287 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25288 let conflicting_binding = builtin_registry()
25289 .get_by_id("codex")
25290 .unwrap()
25291 .capabilities
25292 .adapters
25293 .one_shot
25294 .clone()
25295 .unwrap();
25296 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25297 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25298 pipe.adapter = transport_binding;
25299 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25300 let registry = AgentRegistry::new([spec]).unwrap();
25301 assert!(matches!(
25302 provider_contract_manifest(®istry),
25303 Err(NodeServerError::ProviderContractManifest(message))
25304 if message.contains("conflicting OneShot adapter bindings")
25305 ));
25306 }
25307
25308 #[test]
25309 fn public_node_failures_expose_only_fixed_categories() {
25310 let provider = failure(
25311 NodeFailureCode::BackendOperationFailed,
25312 r"provider bearer-secret failed at C:\private\session.jsonl",
25313 );
25314 assert_eq!(provider.message, "backend-operation-failed");
25315 assert!(!provider.message.contains("bearer-secret"));
25316 assert!(!provider.message.contains(r"C:\private"));
25317 let persistence = persistence_failure(io::Error::new(
25318 io::ErrorKind::Other,
25319 r"state write failed at C:\private\state-v1.json",
25320 ));
25321 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25322 assert!(!persistence.message.contains(r"C:\private"));
25323
25324 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25325 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25326 assert_eq!(
25327 generic.to_string(),
25328 "node durable state failed: durable-state-load-failed",
25329 );
25330 }
25331 }
25332
25333 #[test]
25334 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25335 let sequence = b"\x1b[1;5D".to_vec();
25336 assert_eq!(
25337 terminal_bytes_action(sequence.clone()).unwrap(),
25338 InputAction::TerminalBytes(sequence),
25339 );
25340 assert_eq!(
25341 terminal_bytes_action(Vec::new()).unwrap_err().code,
25342 NodeFailureCode::InvalidRequest,
25343 );
25344 assert_eq!(
25345 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25346 .unwrap_err()
25347 .code,
25348 NodeFailureCode::InvalidRequest,
25349 );
25350 }
25351
25352 #[test]
25353 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25354 let root = temporary_workspace_root("exclusive-state-owner");
25355 std::fs::create_dir_all(&root).unwrap();
25356 let workspace = WorkspaceConfig::new(
25357 WorkspaceId::new("primary").unwrap(),
25358 &root,
25359 )
25360 .unwrap();
25361 let state_path = root.join("state-v1.json");
25362 let endpoint = format!(
25363 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25364 std::process::id(),
25365 std::time::SystemTime::now()
25366 .duration_since(std::time::UNIX_EPOCH)
25367 .unwrap()
25368 .as_nanos(),
25369 );
25370 let config = NodeServerConfig::new(
25371 endpoint,
25372 "fixture-token",
25373 NodeId::new("node-1").unwrap(),
25374 [workspace],
25375 )
25376 .unwrap()
25377 .with_state_path(&state_path)
25378 .unwrap();
25379
25380 let first = NodeServer::new(config.clone()).unwrap();
25381 let error = match NodeServer::new(config.clone()) {
25382 Ok(_) => panic!("a second node server acquired the same durable state path"),
25383 Err(error) => error,
25384 };
25385 assert!(matches!(
25386 &error,
25387 NodeServerError::DurableState(error)
25388 if error.kind() == io::ErrorKind::WouldBlock
25389 ));
25390 assert_eq!(
25391 error.to_string(),
25392 "node durable state failed: durable-state-lock-failed",
25393 );
25394 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25395 drop(first);
25396 let second = NodeServer::new(config).unwrap();
25397 drop(second);
25398 std::fs::remove_dir_all(root).unwrap();
25399 }
25400
25401 #[test]
25402 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25403 let catalog = active_registry().unwrap();
25404 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25405 let workspace_id = WorkspaceId::new("primary").unwrap();
25406 let workspace = WorkspaceConfig::new(
25407 workspace_id.clone(),
25408 std::env::current_dir().unwrap(),
25409 )
25410 .unwrap();
25411 let shared = NodeShared::new(
25412 handle,
25413 "fixture-token".to_owned(),
25414 NodeId::new("node-1").unwrap(),
25415 vec![workspace.clone()],
25416 vec![agent("claude"), agent("codex"), agent("kimi")],
25417 );
25418 let instance_id = AgentInstanceId(17);
25419 let generation = SessionGeneration(3);
25420 assert!(shared.address_for(instance_id, generation).is_none());
25421 shared.bind_session(instance_id, workspace_id.clone(), generation);
25422 let address = SessionAddress {
25423 workspace_id,
25424 session: SessionKey { instance_id, generation },
25425 };
25426 assert_eq!(
25427 shared.workspace_root(&address.workspace_id).unwrap(),
25428 workspace.canonical_root,
25429 );
25430 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25431 shared.remove_binding(&address);
25432 assert!(shared.address_for(instance_id, generation).is_none());
25433 }
25434
25435 #[test]
25436 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25437 let address = SessionAddress {
25438 workspace_id: WorkspaceId::new("primary").unwrap(),
25439 session: SessionKey {
25440 instance_id: AgentInstanceId(7),
25441 generation: SessionGeneration(2),
25442 },
25443 };
25444 let record_id = SessionRecordId::new("record-a").unwrap();
25445 let size = gate4agent_types::TerminalSize {
25446 rows: 24,
25447 columns: 80,
25448 };
25449 let address_requests = [
25450 NodeRequest::Resume {
25451 session: address.clone(),
25452 terminal_size: size,
25453 initial_prompt: None,
25454 },
25455 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25456 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25457 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25458 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25459 NodeRequest::TerminalControl {
25460 session: address.clone(),
25461 control: TerminalControl::Enter,
25462 },
25463 NodeRequest::Resize { session: address.clone(), size },
25464 NodeRequest::Interrupt { session: address.clone() },
25465 NodeRequest::Stop { session: address.clone(), force: false },
25466 NodeRequest::Remove { session: address },
25467 ];
25468 for request in &address_requests {
25469 assert!(request_requires_open_provider_ids_with(
25470 request,
25471 |_| Some(agent("third-party-agent")),
25472 |_| Some(agent("claude")),
25473 ));
25474 assert!(!request_requires_open_provider_ids_with(
25475 request,
25476 |_| Some(agent("claude")),
25477 |_| Some(agent("claude")),
25478 ));
25479 assert!(request_requires_open_provider_ids_with(
25480 request,
25481 |_| None,
25482 |_| Some(agent("claude")),
25483 ));
25484 }
25485
25486 let record_requests = [
25487 NodeRequest::RenameSessionRecord {
25488 record_id: record_id.clone(),
25489 display_name: "renamed".to_owned(),
25490 },
25491 NodeRequest::ResumeSessionRecord {
25492 record_id: record_id.clone(),
25493 terminal_size: size,
25494 initial_prompt: None,
25495 },
25496 NodeRequest::ForgetSessionRecord { record_id },
25497 ];
25498 for request in &record_requests {
25499 assert!(request_requires_open_provider_ids_with(
25500 request,
25501 |_| Some(agent("claude")),
25502 |_| Some(agent("grok")),
25503 ));
25504 assert!(!request_requires_open_provider_ids_with(
25505 request,
25506 |_| Some(agent("claude")),
25507 |_| Some(agent("kimi")),
25508 ));
25509 assert!(request_requires_open_provider_ids_with(
25510 request,
25511 |_| Some(agent("claude")),
25512 |_| None,
25513 ));
25514 }
25515
25516 assert!(request_requires_open_provider_ids_with(
25517 &NodeRequest::Spawn {
25518 workspace_id: WorkspaceId::new("primary").unwrap(),
25519 provider: agent("third-party-agent"),
25520 mode: SessionMode::Pty,
25521 terminal_size: size,
25522 initial_prompt: None,
25523 },
25524 |_| None,
25525 |_| None,
25526 ));
25527 assert!(!request_requires_open_provider_ids_with(
25528 &NodeRequest::Snapshot,
25529 |_| None,
25530 |_| None,
25531 ));
25532 assert!(request_requires_open_provider_ids_with(
25533 &NodeRequest::ForgetContextPack {
25534 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25535 },
25536 |_| None,
25537 |_| None,
25538 ));
25539 }
25540
25541 #[test]
25542 fn legacy_projection_filters_open_record_replies_and_event_history() {
25543 let catalog = active_registry().unwrap();
25544 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25545 let workspace = WorkspaceConfig::new(
25546 WorkspaceId::new("primary").unwrap(),
25547 std::env::current_dir().unwrap(),
25548 )
25549 .unwrap();
25550 let shared = NodeShared::new(
25551 handle,
25552 "fixture-token".to_owned(),
25553 NodeId::new("node-1").unwrap(),
25554 vec![workspace],
25555 vec![agent("claude"), agent("grok")],
25556 );
25557 let legacy = record("claude", "legacy-record");
25558 let open = record("grok", "open-record");
25559 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25560 record: legacy.clone(),
25561 });
25562 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25563 record: open.clone(),
25564 });
25565 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25566 record_id: legacy.record_id.clone(),
25567 });
25568 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25569 record_id: open.record_id.clone(),
25570 });
25571 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25572 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25573 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25574 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25575
25576 let mut record_reply = ResponseEnvelope {
25577 request_id: 1,
25578 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25579 };
25580 project_response_legacy_provider_ids(&shared, &mut record_reply);
25581 assert!(matches!(
25582 record_reply.result,
25583 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25584 ));
25585
25586 let mut resync = ResponseEnvelope {
25587 request_id: 2,
25588 result: Ok(NodeResponse::Resync {
25589 event_sequence: open_removed.sequence,
25590 oldest_available_sequence: 1,
25591 snapshot: shared.snapshot(),
25592 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25593 }),
25594 };
25595 project_response_legacy_provider_ids(&shared, &mut resync);
25596 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25597 panic!("legacy projection changed the resync response kind");
25598 };
25599 assert!(snapshot.session_records.iter().all(|item| {
25600 provider_id_is_legacy(&item.provider)
25601 }));
25602 assert_eq!(events.len(), 2);
25603 assert!(events.iter().all(|event| matches!(
25604 &event.event,
25605 NodeEvent::SessionRecordUpserted { record }
25606 if provider_id_is_legacy(&record.provider)
25607 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25608 }
25609
25610 #[test]
25611 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25612 let catalog = active_registry().unwrap();
25613 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25614 let workspace_id = WorkspaceId::new("primary").unwrap();
25615 let workspace = WorkspaceConfig::new(
25616 workspace_id.clone(),
25617 std::env::current_dir().unwrap(),
25618 )
25619 .unwrap();
25620 let shared = NodeShared::new(
25621 handle,
25622 "fixture-token".to_owned(),
25623 NodeId::new("node-1").unwrap(),
25624 vec![workspace],
25625 vec![agent("claude")],
25626 );
25627 for index in 0..CONTROL_SESSIONS_MAX {
25628 shared.bind_session(
25629 AgentInstanceId((index + 1) as u64),
25630 workspace_id.clone(),
25631 SessionGeneration(1),
25632 );
25633 }
25634 let error = shared.ensure_binding_capacity().unwrap_err();
25635 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25636 }
25637
25638 #[tokio::test]
25639 async fn provider_spawn_admission_rejects_before_session_mutation() {
25640 let catalog = active_registry().unwrap();
25641 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25642 let workspace = WorkspaceConfig::new(
25643 WorkspaceId::new("primary").unwrap(),
25644 std::env::current_dir().unwrap(),
25645 )
25646 .unwrap();
25647 let mut shared = NodeShared::new(
25648 handle,
25649 "fixture-token".to_owned(),
25650 NodeId::new("node-provider-admission").unwrap(),
25651 vec![workspace],
25652 vec![agent("claude")],
25653 );
25654 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25655 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25656 ])
25657 .unwrap();
25658 let terminal_size = gate4agent_types::TerminalSize {
25659 rows: 24,
25660 columns: 80,
25661 };
25662
25663 let unavailable = shared
25664 .spawn_session(
25665 WorkspaceId::new("primary").unwrap(),
25666 agent("claude"),
25667 SessionMode::Pty,
25668 terminal_size,
25669 None,
25670 )
25671 .await
25672 .unwrap_err();
25673 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25674 assert_eq!(unavailable.message, "backend-operation-failed");
25675
25676 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25677 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25678 agent("claude"),
25679 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25680 ),
25681 ])
25682 .unwrap();
25683 let semantic = shared
25684 .spawn_session(
25685 WorkspaceId::new("primary").unwrap(),
25686 agent("claude"),
25687 SessionMode::Inline,
25688 terminal_size,
25689 None,
25690 )
25691 .await
25692 .unwrap_err();
25693 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25694 assert_eq!(semantic.message, "unsupported-capability");
25695
25696 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25697 assert!(shared
25698 .session_bindings
25699 .lock()
25700 .unwrap_or_else(|poisoned| poisoned.into_inner())
25701 .is_empty());
25702 assert!(shared
25703 .session_records
25704 .lock()
25705 .unwrap_or_else(|poisoned| poisoned.into_inner())
25706 .records
25707 .is_empty());
25708 assert!(shared.handle.snapshot().sessions.is_empty());
25709 }
25710
25711 #[tokio::test]
25712 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25713 let catalog = active_registry().unwrap();
25714 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25715 let workspace_id = WorkspaceId::new("primary").unwrap();
25716 let workspace = WorkspaceConfig::new(
25717 workspace_id.clone(),
25718 std::env::current_dir().unwrap(),
25719 )
25720 .unwrap();
25721 let shared = NodeShared::new(
25722 handle,
25723 "fixture-token".to_owned(),
25724 NodeId::new("node-raw-spawn-record").unwrap(),
25725 vec![workspace],
25726 vec![agent("claude")],
25727 );
25728 let address = SessionAddress {
25729 workspace_id,
25730 session: SessionKey {
25731 instance_id: AgentInstanceId(71),
25732 generation: SessionGeneration::default(),
25733 },
25734 };
25735
25736 let record_id = shared
25737 .bind_spawn_session(
25738 &address,
25739 agent("claude"),
25740 SessionMode::Pty,
25741 ProviderRuntimePolicy::raw_pty(),
25742 SpawnRecordPolicy::Always,
25743 None,
25744 )
25745 .unwrap()
25746 .unwrap();
25747 shared
25748 .handle
25749 .dispatch(shared.prepare_command(ControlCommand::Register {
25750 instance_id: address.session.instance_id,
25751 agent_id: agent("claude"),
25752 transport: TransportKind::Pty,
25753 }))
25754 .unwrap();
25755 runtime.tick().await;
25756
25757 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25758 session.instance_id == address.session.instance_id
25759 && session.generation == address.session.generation
25760 }));
25761 let record = shared.record(&record_id).unwrap();
25762 assert_eq!(record.display_name, "claude #1");
25763 assert_eq!(record.state, ManagedSessionState::Live);
25764 assert_eq!(record.active_session.as_ref(), Some(&address));
25765 assert!(record.provider_session.is_none());
25766 let binding_record_id = shared
25767 .session_bindings
25768 .lock()
25769 .unwrap_or_else(|poisoned| poisoned.into_inner())
25770 .get(&address.session.instance_id)
25771 .and_then(|binding| binding.record_id.clone());
25772 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25773
25774 let renamed = shared
25775 .rename_session_record(&record_id, "raw review".to_owned())
25776 .unwrap();
25777 assert_eq!(renamed.record_id, record_id);
25778 assert_eq!(renamed.display_name, "raw review");
25779 shared.reconcile_managed_record(
25780 &record_id,
25781 &address,
25782 &ControlEvent {
25783 sequence: 1,
25784 command_id: None,
25785 instance_id: address.session.instance_id,
25786 generation: address.session.generation,
25787 event: ControlEventKind::Running { process_id: None },
25788 },
25789 false,
25790 false,
25791 );
25792 let running = shared.record(&record_id).unwrap();
25793 assert_eq!(running.state, ManagedSessionState::Live);
25794 assert_eq!(running.active_session.as_ref(), Some(&address));
25795 assert_eq!(running.display_name, "raw review");
25796
25797 shared.reconcile_managed_record(
25798 &record_id,
25799 &address,
25800 &ControlEvent {
25801 sequence: 2,
25802 command_id: None,
25803 instance_id: address.session.instance_id,
25804 generation: address.session.generation,
25805 event: ControlEventKind::Exited {
25806 exit_code: Some(0),
25807 forced: false,
25808 },
25809 },
25810 false,
25811 false,
25812 );
25813 let detached = shared.record(&record_id).unwrap();
25814 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25815 assert!(detached.active_session.is_none());
25816 assert_eq!(detached.display_name, "raw review");
25817 let renamed_detached = shared
25818 .rename_session_record(&record_id, "raw review detached".to_owned())
25819 .unwrap();
25820 assert_eq!(renamed_detached.record_id, record_id);
25821 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25822 assert!(renamed_detached.active_session.is_none());
25823 assert_eq!(renamed_detached.display_name, "raw review detached");
25824 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25825 }
25826
25827 #[test]
25828 fn legacy_raw_spawn_binding_remains_untracked() {
25829 let catalog = active_registry().unwrap();
25830 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25831 let workspace_id = WorkspaceId::new("primary").unwrap();
25832 let workspace = WorkspaceConfig::new(
25833 workspace_id.clone(),
25834 std::env::current_dir().unwrap(),
25835 )
25836 .unwrap();
25837 let shared = NodeShared::new(
25838 handle,
25839 "fixture-token".to_owned(),
25840 NodeId::new("node-legacy-raw-spawn").unwrap(),
25841 vec![workspace],
25842 vec![agent("claude")],
25843 );
25844 let address = SessionAddress {
25845 workspace_id,
25846 session: SessionKey {
25847 instance_id: AgentInstanceId(72),
25848 generation: SessionGeneration::default(),
25849 },
25850 };
25851
25852 let record_id = shared
25853 .bind_spawn_session(
25854 &address,
25855 agent("claude"),
25856 SessionMode::Pty,
25857 ProviderRuntimePolicy::raw_pty(),
25858 SpawnRecordPolicy::ProviderIdentityOnly,
25859 None,
25860 )
25861 .unwrap();
25862
25863 assert!(record_id.is_none());
25864 let binding_record_id = shared
25865 .session_bindings
25866 .lock()
25867 .unwrap_or_else(|poisoned| poisoned.into_inner())
25868 .get(&address.session.instance_id)
25869 .and_then(|binding| binding.record_id.clone());
25870 assert!(binding_record_id.is_none());
25871 assert!(shared.snapshot().session_records.is_empty());
25872 }
25873
25874 #[tokio::test]
25875 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25876 let catalog = active_registry().unwrap();
25877 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25878 let workspace_id = WorkspaceId::new("primary").unwrap();
25879 let workspace = WorkspaceConfig::new(
25880 workspace_id.clone(),
25881 std::env::current_dir().unwrap(),
25882 )
25883 .unwrap();
25884 let mut shared = NodeShared::new(
25885 handle,
25886 "fixture-token".to_owned(),
25887 NodeId::new("node-raw-semantic-gate").unwrap(),
25888 vec![workspace],
25889 vec![agent("claude")],
25890 );
25891 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25892 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25893 ])
25894 .unwrap();
25895 shared
25896 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25897 .unwrap();
25898 let address = SessionAddress {
25899 workspace_id,
25900 session: SessionKey {
25901 instance_id: AgentInstanceId(72),
25902 generation: SessionGeneration::default(),
25903 },
25904 };
25905 shared.bind_session_with_policy(
25906 &address,
25907 ProviderRuntimePolicy::raw_pty(),
25908 None,
25909 );
25910 shared
25911 .handle
25912 .dispatch(shared.prepare_command(ControlCommand::Register {
25913 instance_id: address.session.instance_id,
25914 agent_id: agent("claude"),
25915 transport: TransportKind::Pty,
25916 }))
25917 .unwrap();
25918 runtime.tick().await;
25919 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25920 let revision_before = shared.handle.snapshot().revision;
25921 let history_sequence_before = shared.current_sequence();
25922 let terminal_size = gate4agent_types::TerminalSize {
25923 rows: 24,
25924 columns: 80,
25925 };
25926
25927 for request in [
25928 NodeRequest::Prompt {
25929 session: address.clone(),
25930 text: "prompt".to_owned(),
25931 },
25932 NodeRequest::Paste {
25933 session: address.clone(),
25934 text: "paste".to_owned(),
25935 },
25936 NodeRequest::Resume {
25942 session: address.clone(),
25943 terminal_size,
25944 initial_prompt: Some("resume".to_owned()),
25945 },
25946 ] {
25947 let error = process_request_inner(
25948 &shared,
25949 77,
25950 ClientRole::Operator,
25951 request,
25952 )
25953 .await
25954 .unwrap_err();
25955 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25956 }
25957
25958 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25959 assert_eq!(shared.handle.snapshot().revision, revision_before);
25960 assert_eq!(shared.current_sequence(), history_sequence_before);
25961 let bindings = shared
25962 .session_bindings
25963 .lock()
25964 .unwrap_or_else(|poisoned| poisoned.into_inner());
25965 let binding = bindings.get(&address.session.instance_id).unwrap();
25966 assert_eq!(binding.generation, address.session.generation);
25967 assert!(binding.pending_resume.is_none());
25968 assert!(binding.record_id.is_none());
25969 }
25970
25971 #[tokio::test]
25972 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25973 let catalog = active_registry().unwrap();
25974 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25975 let primary = WorkspaceConfig::new(
25976 WorkspaceId::new("primary").unwrap(),
25977 std::env::current_dir().unwrap(),
25978 )
25979 .unwrap();
25980 let shared = NodeShared::new(
25981 handle,
25982 "fixture-token".to_owned(),
25983 NodeId::new("node-1").unwrap(),
25984 vec![primary],
25985 vec![agent("claude")],
25986 );
25987 shared
25988 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25989 .unwrap();
25990 let before = shared.snapshot();
25991 let error = process_request_inner(
25992 &shared,
25993 77,
25994 ClientRole::Operator,
25995 NodeRequest::RegisterWorkspace {
25996 workspace_id: WorkspaceId::new("foreign").unwrap(),
25997 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25998 },
25999 )
26000 .await
26001 .unwrap_err();
26002 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26003 assert_eq!(error.message, "invalid-request");
26004 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26005 }
26006
26007 #[tokio::test]
26008 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26009 let catalog = active_registry().unwrap();
26010 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26011 let primary_id = WorkspaceId::new("primary").unwrap();
26012 let primary = WorkspaceConfig::new(
26013 primary_id.clone(),
26014 std::env::current_dir().unwrap(),
26015 )
26016 .unwrap();
26017 let shared = NodeShared::new(
26018 handle,
26019 "fixture-token".to_owned(),
26020 NodeId::new("node-1").unwrap(),
26021 vec![primary.clone()],
26022 vec![agent("claude")],
26023 );
26024 let secondary_id = WorkspaceId::new("secondary").unwrap();
26025 let secondary = shared
26026 .register_workspace(
26027 secondary_id.clone(),
26028 std::env::temp_dir().to_string_lossy().into_owned(),
26029 )
26030 .await
26031 .unwrap();
26032 assert_eq!(secondary.workspace_id, secondary_id);
26033 assert!(secondary.sessions.is_empty());
26034 assert_eq!(shared.snapshot().workspaces.len(), 2);
26035
26036 let duplicate_id = shared
26037 .register_workspace(
26038 secondary_id.clone(),
26039 primary.canonical_root().to_owned(),
26040 )
26041 .await
26042 .unwrap_err();
26043 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26044 let duplicate_root = shared
26045 .register_workspace(
26046 WorkspaceId::new("duplicate-root").unwrap(),
26047 windows_path_text(&secondary.canonical_root).to_owned(),
26048 )
26049 .await
26050 .unwrap_err();
26051 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26052
26053 let address = SessionAddress {
26054 workspace_id: secondary_id.clone(),
26055 session: SessionKey {
26056 instance_id: AgentInstanceId(41),
26057 generation: SessionGeneration(1),
26058 },
26059 };
26060 shared.bind_session(
26061 address.session.instance_id,
26062 secondary_id.clone(),
26063 address.session.generation,
26064 );
26065 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26066 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26067 shared.remove_binding(&address);
26068 shared.unregister_workspace(&secondary_id).unwrap();
26069 assert_eq!(shared.snapshot().workspaces.len(), 1);
26070
26071 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26072 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26073 let history = shared
26074 .history
26075 .lock()
26076 .unwrap_or_else(|poisoned| poisoned.into_inner());
26077 assert!(matches!(
26078 &history.events[0],
26079 NodeEventEnvelope {
26080 sequence: 1,
26081 event: NodeEvent::WorkspaceAdded { workspace },
26082 } if workspace.workspace_id == secondary_id
26083 ));
26084 assert!(matches!(
26085 &history.events[1],
26086 NodeEventEnvelope {
26087 sequence: 2,
26088 event: NodeEvent::WorkspaceRemoved { workspace_id },
26089 } if workspace_id == &secondary_id
26090 ));
26091 }
26092
26093 #[tokio::test]
26094 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26095 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26096 std::fs::create_dir_all(&secondary_root).unwrap();
26097 let catalog = active_registry().unwrap();
26098 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26099 let primary_id = WorkspaceId::new("primary").unwrap();
26100 let secondary_id = WorkspaceId::new("secondary").unwrap();
26101 let shared = NodeShared::new(
26102 handle,
26103 "fixture-token".to_owned(),
26104 NodeId::new("node-session-reference-index").unwrap(),
26105 vec![
26106 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26107 .unwrap(),
26108 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26109 ],
26110 vec![agent("claude"), agent("codex")],
26111 );
26112 let identity = ProviderSessionIdentity {
26113 key: gate4agent_types::ProviderSessionKey::SessionId,
26114 id: "provider-session-42".to_owned(),
26115 transcript_path: None,
26116 };
26117 let index_request = |workspace_id: WorkspaceId,
26118 provider: AgentId,
26119 identity: ProviderSessionIdentity,
26120 display_name: &str| {
26121 NodeRequest::IndexProviderSession {
26122 workspace_id,
26123 provider,
26124 identity,
26125 display_name: display_name.to_owned(),
26126 }
26127 };
26128 let request = index_request(
26129 primary_id.clone(),
26130 agent("claude"),
26131 identity.clone(),
26132 "release shepherd",
26133 );
26134 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26135 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26136 }));
26137 let observer = process_request_inner(
26138 &shared,
26139 77,
26140 ClientRole::Observer,
26141 request.clone(),
26142 )
26143 .await
26144 .unwrap_err();
26145 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26146 let uncontrolled = process_request_inner(
26147 &shared,
26148 77,
26149 ClientRole::Operator,
26150 request.clone(),
26151 )
26152 .await
26153 .unwrap_err();
26154 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26155 shared
26156 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26157 .unwrap();
26158
26159 let indexed = process_request_inner(
26160 &shared,
26161 77,
26162 ClientRole::Operator,
26163 request.clone(),
26164 )
26165 .await
26166 .unwrap();
26167 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26168 panic!("provider session reference index returned another response");
26169 };
26170 assert_eq!(record.state, ManagedSessionState::Dormant);
26171 assert_eq!(record.mode, SessionMode::Pty);
26172 assert_eq!(record.workspace_id, primary_id);
26173 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26174 assert!(record.active_session.is_none());
26175 assert!(record.environment_profile.is_none());
26176 assert!(record.bundle.is_none());
26177 assert!(record.context.is_none());
26178
26179 let repeated = process_request_inner(
26180 &shared,
26181 77,
26182 ClientRole::Operator,
26183 index_request(
26184 primary_id.clone(),
26185 agent("claude"),
26186 identity.clone(),
26187 "ignored replacement",
26188 ),
26189 )
26190 .await
26191 .unwrap();
26192 assert!(matches!(
26193 repeated,
26194 NodeResponse::ProviderSessionIndexed { record: ref existing }
26195 if existing.record_id == record.record_id
26196 && existing.display_name == "release shepherd"
26197 ));
26198 let conflicting_workspace = process_request_inner(
26199 &shared,
26200 77,
26201 ClientRole::Operator,
26202 index_request(
26203 secondary_id,
26204 agent("claude"),
26205 identity.clone(),
26206 "release shepherd",
26207 ),
26208 )
26209 .await
26210 .unwrap_err();
26211 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26212 let conflicting_provider = process_request_inner(
26213 &shared,
26214 77,
26215 ClientRole::Operator,
26216 index_request(
26217 primary_id.clone(),
26218 agent("codex"),
26219 identity.clone(),
26220 "release shepherd",
26221 ),
26222 )
26223 .await
26224 .unwrap_err();
26225 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26226 let transcript_rejected = process_request_inner(
26227 &shared,
26228 77,
26229 ClientRole::Operator,
26230 index_request(
26231 primary_id,
26232 agent("claude"),
26233 ProviderSessionIdentity {
26234 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26235 ..identity
26236 },
26237 "release shepherd",
26238 ),
26239 )
26240 .await
26241 .unwrap_err();
26242 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26243 let upserts = shared
26244 .history
26245 .lock()
26246 .unwrap_or_else(|poisoned| poisoned.into_inner())
26247 .events
26248 .iter()
26249 .filter(|event| matches!(
26250 &event.event,
26251 NodeEvent::SessionRecordUpserted { record: published }
26252 if published.record_id == record.record_id
26253 ))
26254 .count();
26255 assert_eq!(upserts, 1);
26256 std::fs::remove_dir_all(secondary_root).unwrap();
26257 }
26258
26259 #[tokio::test]
26260 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26261 let fixture_root = temporary_workspace_root("standalone-independent");
26262 let target = fixture_root.join("independent");
26263 std::fs::create_dir_all(&fixture_root).unwrap();
26264 let catalog = active_registry().unwrap();
26265 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26266 let selected = WorkspaceConfig::new(
26267 WorkspaceId::new("primary").unwrap(),
26268 std::env::current_dir().unwrap(),
26269 )
26270 .unwrap();
26271 let selected_root = selected.canonical_root().to_owned();
26272 let shared = NodeShared::new(
26273 handle,
26274 "fixture-token".to_owned(),
26275 NodeId::new("node-standalone").unwrap(),
26276 vec![selected],
26277 vec![agent("claude")],
26278 );
26279 let workspace_id = WorkspaceId::new("independent").unwrap();
26280 let request = NodeRequest::CreateStandaloneWorkspace {
26281 workspace_id: workspace_id.clone(),
26282 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26283 initial_branch: Some("main".to_owned()),
26284 };
26285 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26286 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26287 }));
26288 assert!(!request_uses_unnegotiated_capability(
26289 &request,
26290 &baseline_capabilities().unwrap(),
26291 ));
26292 assert!(request_uses_unnegotiated_capability(&request, &[]));
26293
26294 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26295 .await
26296 .unwrap_err();
26297 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26298 assert!(!target.exists());
26299 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26300 .await
26301 .unwrap_err();
26302 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26303 assert!(!target.exists());
26304 shared
26305 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26306 .unwrap();
26307
26308 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26309 .await
26310 .unwrap();
26311 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26312 panic!("standalone workspace creation returned another response");
26313 };
26314 assert_eq!(workspace.workspace_id, workspace_id);
26315 assert!(target.join(".git").is_dir());
26316 let branch = run_git_bounded(
26317 target.to_str().unwrap(),
26318 &["branch", "--show-current"],
26319 GIT_OUTPUT_MAX_BYTES,
26320 )
26321 .await
26322 .unwrap();
26323 assert!(branch.success);
26324 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26325 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26326 assert_eq!(worktrees.len(), 1);
26327 let listed_root = WorkspaceConfig::new(
26328 WorkspaceId::new("listed-independent").unwrap(),
26329 &worktrees[0].path,
26330 )
26331 .unwrap();
26332 assert!(platform::roots_equal(
26333 listed_root.canonical_root(),
26334 windows_path_text(&workspace.canonical_root),
26335 ));
26336 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26337 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26338 snapshot.workspace_id == workspace_id
26339 && snapshot.canonical_root == workspace.canonical_root
26340 }));
26341 assert!(shared
26342 .history
26343 .lock()
26344 .unwrap_or_else(|poisoned| poisoned.into_inner())
26345 .events
26346 .iter()
26347 .any(|event| matches!(
26348 &event.event,
26349 NodeEvent::WorkspaceAdded { workspace }
26350 if workspace.workspace_id == workspace_id
26351 )));
26352 std::fs::remove_dir_all(fixture_root).unwrap();
26353 }
26354
26355 #[tokio::test]
26356 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26357 let fixture_root = temporary_workspace_root("standalone-compensation");
26358 let created_target = fixture_root.join("created-target");
26359 let existing_target = fixture_root.join("existing-target");
26360 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26361 let invalid_state_path = invalid_state_parent.join("state.json");
26362 std::fs::create_dir_all(&existing_target).unwrap();
26363 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26364 let catalog = active_registry().unwrap();
26365 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26366 let primary = WorkspaceConfig::new(
26367 WorkspaceId::new("primary").unwrap(),
26368 std::env::current_dir().unwrap(),
26369 )
26370 .unwrap();
26371 let mut shared = NodeShared::new(
26372 handle,
26373 "fixture-token".to_owned(),
26374 NodeId::new("node-standalone-compensation").unwrap(),
26375 vec![primary],
26376 vec![agent("claude")],
26377 );
26378 shared.state_path = Some(invalid_state_path);
26379
26380 let created_recovery = shared
26381 .create_standalone_workspace(
26382 WorkspaceId::new("compensated").unwrap(),
26383 created_target.to_string_lossy().into_owned(),
26384 Some("main".to_owned()),
26385 )
26386 .await
26387 .unwrap_err();
26388 assert_eq!(
26389 created_recovery.code,
26390 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26391 );
26392 assert!(created_target.join(".git").is_dir());
26393 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26394 workspace.workspace_id.as_str() != "compensated"
26395 }));
26396
26397 let existing_recovery = shared
26398 .create_standalone_workspace(
26399 WorkspaceId::new("restored").unwrap(),
26400 existing_target.to_string_lossy().into_owned(),
26401 None,
26402 )
26403 .await
26404 .unwrap_err();
26405 assert_eq!(
26406 existing_recovery.code,
26407 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26408 );
26409 assert!(existing_target.is_dir());
26410 assert!(existing_target.join(".git").is_dir());
26411 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26412 workspace.workspace_id.as_str() != "restored"
26413 }));
26414
26415 let registered_empty = fixture_root.join("registered-empty");
26416 std::fs::create_dir(®istered_empty).unwrap();
26417 let catalog = active_registry().unwrap();
26418 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26419 let registered = WorkspaceConfig::new(
26420 WorkspaceId::new("registered-empty").unwrap(),
26421 ®istered_empty,
26422 )
26423 .unwrap();
26424 let duplicate_shared = NodeShared::new(
26425 handle,
26426 "fixture-token".to_owned(),
26427 NodeId::new("node-standalone-duplicate-root").unwrap(),
26428 vec![registered],
26429 vec![agent("claude")],
26430 );
26431 let duplicate = duplicate_shared
26432 .create_standalone_workspace(
26433 WorkspaceId::new("duplicate-root").unwrap(),
26434 registered_empty.to_string_lossy().into_owned(),
26435 None,
26436 )
26437 .await
26438 .unwrap_err();
26439 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26440 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26441 std::fs::remove_dir_all(fixture_root).unwrap();
26442 }
26443
26444 #[test]
26445 fn managed_record_id_collision_preserves_the_existing_record() {
26446 let catalog = active_registry().unwrap();
26447 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26448 let workspace = WorkspaceConfig::new(
26449 WorkspaceId::new("primary").unwrap(),
26450 std::env::current_dir().unwrap(),
26451 )
26452 .unwrap();
26453 let shared = NodeShared::new(
26454 handle,
26455 "fixture-token".to_owned(),
26456 NodeId::new("node-1").unwrap(),
26457 vec![workspace.clone()],
26458 vec![agent("claude")],
26459 );
26460 let record_id = SessionRecordId::new("sr-collision").unwrap();
26461 let original = ManagedSessionRecord {
26462 record_id: record_id.clone(),
26463 display_name: "original".to_owned(),
26464 provider: agent("claude"),
26465 mode: SessionMode::Pty,
26466 state: ManagedSessionState::Dormant,
26467 workspace_id: workspace.workspace_id,
26468 canonical_root: opaque_windows_path(workspace.canonical_root),
26469 provider_session: None,
26470 active_session: None,
26471 environment_profile: None,
26472 bundle: None,
26473 context_id: None,
26474 context: None,
26475 exported_context: None,
26476 task_binding: None,
26477 created_at_unix_ms: 1,
26478 updated_at_unix_ms: 1,
26479 last_error: None,
26480 };
26481 shared.insert_record(original.clone()).unwrap();
26482 let mut collision = original.clone();
26483 collision.display_name = "replacement".to_owned();
26484 let error = shared.insert_record(collision).unwrap_err();
26485 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26486 assert_eq!(shared.record(&record_id).unwrap(), original);
26487 }
26488
26489 #[test]
26490 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26491 let catalog = active_registry().unwrap();
26492 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26493 let workspace = WorkspaceConfig::new(
26494 WorkspaceId::new("primary").unwrap(),
26495 std::env::current_dir().unwrap(),
26496 )
26497 .unwrap();
26498 let shared = NodeShared::new(
26499 handle,
26500 "fixture-token".to_owned(),
26501 NodeId::new("node-1").unwrap(),
26502 vec![workspace.clone()],
26503 vec![agent("claude")],
26504 );
26505 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26506 shared
26507 .insert_record(ManagedSessionRecord {
26508 record_id: record_id.clone(),
26509 display_name: "runtime error".to_owned(),
26510 provider: agent("claude"),
26511 mode: SessionMode::Pty,
26512 state: ManagedSessionState::IdentityPending,
26513 workspace_id: workspace.workspace_id,
26514 canonical_root: opaque_windows_path(workspace.canonical_root),
26515 provider_session: None,
26516 active_session: None,
26517 environment_profile: None,
26518 bundle: None,
26519 context_id: None,
26520 context: None,
26521 exported_context: None,
26522 task_binding: None,
26523 created_at_unix_ms: 1,
26524 updated_at_unix_ms: 1,
26525 last_error: None,
26526 })
26527 .unwrap();
26528
26529 shared
26530 .mark_record_error(
26531 &record_id,
26532 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26533 )
26534 .unwrap();
26535 let record = shared.record(&record_id).unwrap();
26536 assert_eq!(record.state, ManagedSessionState::Unavailable);
26537 assert_eq!(
26538 record.last_error.as_deref(),
26539 Some("provider-runtime-failed"),
26540 );
26541 let snapshot = shared.snapshot();
26542 assert_eq!(snapshot.session_records, vec![record]);
26543 }
26544
26545 #[test]
26554 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26555 let catalog = active_registry().unwrap();
26556 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26557 let workspace = WorkspaceConfig::new(
26558 WorkspaceId::new("primary").unwrap(),
26559 std::env::current_dir().unwrap(),
26560 )
26561 .unwrap();
26562 let shared = NodeShared::new(
26563 handle,
26564 "fixture-token".to_owned(),
26565 NodeId::new("node-1").unwrap(),
26566 vec![workspace],
26567 vec![agent("claude")],
26568 );
26569
26570 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26571
26572 shared.record_rejected_command(
26573 CommandId(1),
26574 "agent 'claude' does not support transport Acp".to_owned(),
26575 true,
26576 );
26577 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26579
26580 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26581 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26582 assert!(reason.unsupported_transport);
26583
26584 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26586
26587 shared.record_rejected_command(
26592 CommandId(3),
26593 "backend busy".to_owned(),
26594 false,
26595 );
26596 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26597 assert!(!unclassified.unsupported_transport);
26598 }
26599
26600 #[test]
26601 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26602 let catalog = active_registry().unwrap();
26603 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26604 let workspace = WorkspaceConfig::new(
26605 WorkspaceId::new("primary").unwrap(),
26606 std::env::current_dir().unwrap(),
26607 )
26608 .unwrap();
26609 let shared = NodeShared::new(
26610 handle,
26611 "fixture-token".to_owned(),
26612 NodeId::new("node-identity-policy").unwrap(),
26613 vec![workspace.clone()],
26614 vec![agent("claude")],
26615 );
26616 let address = SessionAddress {
26617 workspace_id: workspace.workspace_id.clone(),
26618 session: SessionKey {
26619 instance_id: AgentInstanceId(90),
26620 generation: SessionGeneration(1),
26621 },
26622 };
26623 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26624 shared
26625 .insert_record(ManagedSessionRecord {
26626 record_id: record_id.clone(),
26627 display_name: "policy denied".to_owned(),
26628 provider: agent("claude"),
26629 mode: SessionMode::Pty,
26630 state: ManagedSessionState::IdentityPending,
26631 workspace_id: workspace.workspace_id,
26632 canonical_root: opaque_windows_path(workspace.canonical_root),
26633 provider_session: None,
26634 active_session: Some(address.clone()),
26635 environment_profile: None,
26636 bundle: None,
26637 context_id: None,
26638 context: None,
26639 exported_context: None,
26640 task_binding: None,
26641 created_at_unix_ms: 1,
26642 updated_at_unix_ms: 1,
26643 last_error: None,
26644 })
26645 .unwrap();
26646 let event = ControlEvent {
26647 sequence: 1,
26648 command_id: None,
26649 instance_id: address.session.instance_id,
26650 generation: address.session.generation,
26651 event: ControlEventKind::ProviderEvent {
26652 sequence: 1,
26653 source: gate4agent_types::ProviderSource {
26654 family: AdapterFamily::PtySemantic,
26655 binding: AdapterBinding::new(
26656 gate4agent_types::AdapterId::new("claude").unwrap(),
26657 "fixture/v1",
26658 gate4agent_types::AdapterVerification::SyntheticFixture,
26659 )
26660 .unwrap(),
26661 },
26662 source_sequence: 1,
26663 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26664 identity: gate4agent_types::ProviderSessionIdentity {
26665 key: gate4agent_types::ProviderSessionKey::SessionId,
26666 id: "provider-session".to_owned(),
26667 transcript_path: None,
26668 },
26669 },
26670 },
26671 };
26672
26673 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26674
26675 let record = shared.record(&record_id).unwrap();
26676 assert!(record.provider_session.is_none());
26677 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26678 assert_eq!(record.active_session.as_ref(), Some(&address));
26679 }
26680
26681 #[test]
26682 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26683 let secondary_root = temporary_workspace_root("identity-scope");
26684 std::fs::create_dir_all(&secondary_root).unwrap();
26685 let catalog = active_registry().unwrap();
26686 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26687 let primary = WorkspaceConfig::new(
26688 WorkspaceId::new("primary").unwrap(),
26689 std::env::current_dir().unwrap(),
26690 )
26691 .unwrap();
26692 let secondary = WorkspaceConfig::new(
26693 WorkspaceId::new("secondary").unwrap(),
26694 &secondary_root,
26695 )
26696 .unwrap();
26697 let shared = NodeShared::new(
26698 handle,
26699 "fixture-token".to_owned(),
26700 NodeId::new("node-1").unwrap(),
26701 vec![primary.clone(), secondary.clone()],
26702 vec![agent("claude")],
26703 );
26704 let identity = gate4agent_types::ProviderSessionIdentity {
26705 key: gate4agent_types::ProviderSessionKey::SessionId,
26706 id: "provider-session".to_owned(),
26707 transcript_path: None,
26708 };
26709 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26710 shared
26711 .insert_record(ManagedSessionRecord {
26712 record_id: dormant_id.clone(),
26713 display_name: "dormant".to_owned(),
26714 provider: agent("claude"),
26715 mode: SessionMode::Pty,
26716 state: ManagedSessionState::Dormant,
26717 workspace_id: primary.workspace_id,
26718 canonical_root: opaque_windows_path(primary.canonical_root),
26719 provider_session: Some(identity.clone()),
26720 active_session: None,
26721 environment_profile: None,
26722 bundle: None,
26723 context_id: None,
26724 context: None,
26725 exported_context: None,
26726 task_binding: None,
26727 created_at_unix_ms: 1,
26728 updated_at_unix_ms: 1,
26729 last_error: None,
26730 })
26731 .unwrap();
26732 let address = SessionAddress {
26733 workspace_id: secondary.workspace_id.clone(),
26734 session: SessionKey {
26735 instance_id: AgentInstanceId(91),
26736 generation: SessionGeneration(1),
26737 },
26738 };
26739 let current_id = SessionRecordId::new("sr-current").unwrap();
26740 shared
26741 .insert_record(ManagedSessionRecord {
26742 record_id: current_id.clone(),
26743 display_name: "current".to_owned(),
26744 provider: agent("claude"),
26745 mode: SessionMode::Pty,
26746 state: ManagedSessionState::IdentityPending,
26747 workspace_id: secondary.workspace_id,
26748 canonical_root: opaque_windows_path(secondary.canonical_root),
26749 provider_session: None,
26750 active_session: Some(address.clone()),
26751 environment_profile: None,
26752 bundle: None,
26753 context_id: None,
26754 context: None,
26755 exported_context: None,
26756 task_binding: None,
26757 created_at_unix_ms: 2,
26758 updated_at_unix_ms: 2,
26759 last_error: None,
26760 })
26761 .unwrap();
26762 shared.bind_managed_session(
26763 &address,
26764 current_id.clone(),
26765 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26766 None,
26767 );
26768 let event = ControlEvent {
26769 sequence: 1,
26770 command_id: None,
26771 instance_id: address.session.instance_id,
26772 generation: address.session.generation,
26773 event: ControlEventKind::ProviderEvent {
26774 sequence: 1,
26775 source: gate4agent_types::ProviderSource {
26776 family: gate4agent_types::AdapterFamily::PtySemantic,
26777 binding: gate4agent_types::AdapterBinding::new(
26778 gate4agent_types::AdapterId::new("claude").unwrap(),
26779 "fixture/v1",
26780 gate4agent_types::AdapterVerification::SyntheticFixture,
26781 )
26782 .unwrap(),
26783 },
26784 source_sequence: 1,
26785 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26786 identity,
26787 },
26788 },
26789 };
26790 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26791
26792 let binding = shared
26793 .session_bindings
26794 .lock()
26795 .unwrap_or_else(|poisoned| poisoned.into_inner())
26796 .get(&address.session.instance_id)
26797 .cloned()
26798 .unwrap();
26799 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26800 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26801 let current = shared.record(¤t_id).unwrap();
26802 assert_eq!(current.state, ManagedSessionState::Unavailable);
26803 assert_eq!(current.active_session.as_ref(), Some(&address));
26804 assert_eq!(
26805 current.last_error.as_deref(),
26806 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26807 );
26808 std::fs::remove_dir_all(secondary_root).unwrap();
26809 }
26810
26811 #[tokio::test]
26812 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26813 let root = temporary_workspace_root("semantic-provider-session");
26814 std::fs::create_dir_all(&root).unwrap();
26815 let state_path = root.join("state-v1.json");
26816 let catalog = active_registry().unwrap();
26817 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26818 let workspace = WorkspaceConfig::new(
26819 WorkspaceId::new("primary").unwrap(),
26820 &root,
26821 )
26822 .unwrap();
26823 let node_id = NodeId::new("node-semantic-identity").unwrap();
26824 let shared = NodeShared::new_with_incarnation(
26825 handle,
26826 "fixture-token".to_owned(),
26827 node_id.clone(),
26828 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26829 vec![workspace.clone()],
26830 vec![agent("claude")],
26831 ProviderRuntimeStatuses::default(),
26832 None,
26833 Vec::new(),
26834 Vec::new(),
26835 SpawnProfileRegistry::default(),
26836 None,
26837 None,
26838 None,
26839 Some(state_path.clone()),
26840 Vec::new(),
26841 Vec::new(),
26842 Vec::new(),
26843 Vec::new(),
26844 None,
26845 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26846 );
26847 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26848 key: gate4agent_types::ProviderSessionKey::SessionId,
26849 id: "provider-session".to_owned(),
26850 transcript_path: None,
26851 };
26852 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26853 shared
26854 .insert_record(ManagedSessionRecord {
26855 record_id: durable_id.clone(),
26856 display_name: "durable".to_owned(),
26857 provider: agent("claude"),
26858 mode: SessionMode::Pty,
26859 state: ManagedSessionState::Dormant,
26860 workspace_id: workspace.workspace_id.clone(),
26861 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26862 provider_session: Some(persisted_identity.clone()),
26863 active_session: None,
26864 environment_profile: None,
26865 bundle: None,
26866 context_id: None,
26867 context: None,
26868 exported_context: None,
26869 task_binding: None,
26870 created_at_unix_ms: 1,
26871 updated_at_unix_ms: 1,
26872 last_error: None,
26873 })
26874 .unwrap();
26875 let address = SessionAddress {
26876 workspace_id: workspace.workspace_id.clone(),
26877 session: SessionKey {
26878 instance_id: AgentInstanceId(92),
26879 generation: SessionGeneration(2),
26880 },
26881 };
26882 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26883 shared
26884 .insert_record(ManagedSessionRecord {
26885 record_id: pending_id.clone(),
26886 display_name: "pending".to_owned(),
26887 provider: agent("claude"),
26888 mode: SessionMode::Pty,
26889 state: ManagedSessionState::IdentityPending,
26890 workspace_id: workspace.workspace_id.clone(),
26891 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26892 provider_session: None,
26893 active_session: Some(address.clone()),
26894 environment_profile: None,
26895 bundle: None,
26896 context_id: None,
26897 context: None,
26898 exported_context: None,
26899 task_binding: None,
26900 created_at_unix_ms: 2,
26901 updated_at_unix_ms: 2,
26902 last_error: None,
26903 })
26904 .unwrap();
26905 shared.bind_managed_session(
26906 &address,
26907 pending_id.clone(),
26908 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26909 None,
26910 );
26911 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26912 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26913 ..persisted_identity
26914 };
26915 let event = ControlEvent {
26916 sequence: 1,
26917 command_id: None,
26918 instance_id: address.session.instance_id,
26919 generation: address.session.generation,
26920 event: ControlEventKind::ProviderEvent {
26921 sequence: 1,
26922 source: gate4agent_types::ProviderSource {
26923 family: gate4agent_types::AdapterFamily::PtySemantic,
26924 binding: gate4agent_types::AdapterBinding::new(
26925 gate4agent_types::AdapterId::new("claude").unwrap(),
26926 "fixture/v1",
26927 gate4agent_types::AdapterVerification::SyntheticFixture,
26928 )
26929 .unwrap(),
26930 },
26931 source_sequence: 1,
26932 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26933 identity: observed_identity.clone(),
26934 },
26935 },
26936 };
26937 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26938
26939 let snapshot = shared.snapshot();
26940 assert_eq!(snapshot.session_records.len(), 1);
26941 let live = &snapshot.session_records[0];
26942 assert_eq!(live.record_id, durable_id);
26943 assert_eq!(live.state, ManagedSessionState::Live);
26944 assert_eq!(live.active_session.as_ref(), Some(&address));
26945 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26946 let binding = shared
26947 .session_bindings
26948 .lock()
26949 .unwrap_or_else(|poisoned| poisoned.into_inner())
26950 .get(&address.session.instance_id)
26951 .cloned()
26952 .unwrap();
26953 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26954
26955 let mut duplicate = live.clone();
26956 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26957 duplicate.display_name = "duplicate".to_owned();
26958 duplicate.state = ManagedSessionState::Dormant;
26959 duplicate.active_session = None;
26960 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26961 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26962 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26963 assert_eq!(shared.snapshot().session_records.len(), 1);
26964
26965 let terminal_size = gate4agent_types::TerminalSize {
26966 rows: 24,
26967 columns: 80,
26968 };
26969 let (first_busy, second_busy) = tokio::join!(
26970 shared.resume_session_record(&durable_id, terminal_size, None),
26971 shared.resume_session_record(&durable_id, terminal_size, None),
26972 );
26973 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26974 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26975 assert_eq!(busy.message, "session-record-busy");
26976 }
26977
26978 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26979 assert_eq!(loaded.records.len(), 1);
26980 assert_eq!(loaded.records[0].record_id, durable_id);
26981 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26982 assert_eq!(
26983 loaded.records[0]
26984 .provider_session
26985 .as_ref()
26986 .unwrap()
26987 .transcript_path,
26988 None,
26989 );
26990 let catalog = active_registry().unwrap();
26991 let (reloaded_handle, _reloaded_runtime) =
26992 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26993 let reloaded = NodeShared::new_with_incarnation(
26994 reloaded_handle,
26995 "fixture-token".to_owned(),
26996 node_id,
26997 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26998 vec![workspace],
26999 vec![agent("claude")],
27000 ProviderRuntimeStatuses::default(),
27001 None,
27002 Vec::new(),
27003 Vec::new(),
27004 SpawnProfileRegistry::default(),
27005 None,
27006 None,
27007 None,
27008 None,
27009 loaded.records,
27010 loaded.managed_worktrees,
27011 loaded.managed_worktree_tombstones,
27012 loaded.materializations,
27013 loaded.warning,
27014 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27015 );
27016 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27017 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27018 std::fs::remove_dir_all(root).unwrap();
27019 }
27020
27021 #[tokio::test]
27022 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27023 let secondary_root = temporary_workspace_root("workspace-restore");
27024 let other_root = temporary_workspace_root("workspace-conflict");
27025 std::fs::create_dir_all(&secondary_root).unwrap();
27026 std::fs::create_dir_all(&other_root).unwrap();
27027 let catalog = active_registry().unwrap();
27028 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27029 let primary = WorkspaceConfig::new(
27030 WorkspaceId::new("primary").unwrap(),
27031 std::env::current_dir().unwrap(),
27032 )
27033 .unwrap();
27034 let secondary = WorkspaceConfig::new(
27035 WorkspaceId::new("secondary").unwrap(),
27036 &secondary_root,
27037 )
27038 .unwrap();
27039 let shared = NodeShared::new(
27040 handle,
27041 "fixture-token".to_owned(),
27042 NodeId::new("node-1").unwrap(),
27043 vec![primary, secondary.clone()],
27044 vec![agent("claude")],
27045 );
27046 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27047 shared
27048 .insert_record(ManagedSessionRecord {
27049 record_id: record_id.clone(),
27050 display_name: "workspace record".to_owned(),
27051 provider: agent("claude"),
27052 mode: SessionMode::Pty,
27053 state: ManagedSessionState::Dormant,
27054 workspace_id: secondary.workspace_id.clone(),
27055 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27056 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27057 key: gate4agent_types::ProviderSessionKey::SessionId,
27058 id: "provider-session".to_owned(),
27059 transcript_path: None,
27060 }),
27061 active_session: None,
27062 environment_profile: None,
27063 bundle: None,
27064 context_id: None,
27065 context: None,
27066 exported_context: None,
27067 task_binding: None,
27068 created_at_unix_ms: 1,
27069 updated_at_unix_ms: 1,
27070 last_error: None,
27071 })
27072 .unwrap();
27073 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27074 let unavailable = shared.record(&record_id).unwrap();
27075 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27076 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27077
27078 let conflict = shared
27079 .register_workspace(
27080 secondary.workspace_id.clone(),
27081 other_root.to_string_lossy().into_owned(),
27082 )
27083 .await
27084 .unwrap_err();
27085 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27086 shared
27087 .register_workspace(
27088 secondary.workspace_id,
27089 secondary_root.to_string_lossy().into_owned(),
27090 )
27091 .await
27092 .unwrap();
27093 let restored = shared.record(&record_id).unwrap();
27094 assert_eq!(restored.state, ManagedSessionState::Dormant);
27095 assert!(restored.last_error.is_none());
27096 std::fs::remove_dir_all(secondary_root).unwrap();
27097 std::fs::remove_dir_all(other_root).unwrap();
27098 }
27099
27100 #[test]
27101 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27102 let error = validate_workspace_request_root(
27103 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27104 )
27105 .unwrap_err();
27106 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27107 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27108 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27109 }
27110
27111 #[test]
27112 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27113 let catalog = active_registry().unwrap();
27114 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27115 let workspace = WorkspaceConfig::new(
27116 WorkspaceId::new("primary").unwrap(),
27117 std::env::current_dir().unwrap(),
27118 )
27119 .unwrap();
27120 let shared = NodeShared::new(
27121 handle,
27122 "fixture-token".to_owned(),
27123 NodeId::new("node-1").unwrap(),
27124 vec![workspace],
27125 vec![agent("claude")],
27126 );
27127 let publishers = 4_u64;
27128 let events_per_publisher = 256_u64;
27129 let start = std::sync::Barrier::new(publishers as usize + 1);
27130 let finished = AtomicU64::new(0);
27131
27132 std::thread::scope(|scope| {
27133 for _ in 0..publishers {
27134 scope.spawn(|| {
27135 start.wait();
27136 for _ in 0..events_per_publisher {
27137 shared.publish(NodeEvent::ControllerChanged { controller: None });
27138 }
27139 finished.fetch_add(1, Ordering::Release);
27140 });
27141 }
27142 start.wait();
27143 while finished.load(Ordering::Acquire) != publishers {
27144 assert_resync_history_is_linearized(shared.resync(0));
27145 std::thread::yield_now();
27146 }
27147 });
27148 let response = shared.resync(0);
27149 assert_resync_history_is_linearized(response.clone());
27150 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27151 unreachable!("resync helper returned another response");
27152 };
27153 assert_eq!(event_sequence, publishers * events_per_publisher);
27154 assert_eq!(events.len() as u64, event_sequence);
27155 }
27156
27157 fn assert_resync_history_is_linearized(response: NodeResponse) {
27158 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27159 unreachable!("resync helper returned another response");
27160 };
27161 assert!(events.windows(2).all(|pair| {
27162 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27163 }));
27164 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27165 }
27166
27167 #[test]
27168 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27169 assert_eq!(
27170 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27171 r"C:\repo\worktree",
27172 );
27173 assert_eq!(
27174 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27175 r"\\server\share\repo",
27176 );
27177 assert!(validate_workspace_root(
27178 &WorkspaceId::new("network").unwrap(),
27179 r"\\server\share\repo",
27180 )
27181 .is_err());
27182 let workspace = WorkspaceConfig::new(
27183 WorkspaceId::new("primary").unwrap(),
27184 std::env::current_dir().unwrap(),
27185 )
27186 .unwrap();
27187 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27188 }
27189
27190 #[test]
27191 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27192 let catalog = active_registry().unwrap();
27193 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27194 let workspace_id = WorkspaceId::new("primary").unwrap();
27195 let workspace = WorkspaceConfig::new(
27196 workspace_id.clone(),
27197 std::env::current_dir().unwrap(),
27198 )
27199 .unwrap();
27200 let shared = NodeShared::new(
27201 handle,
27202 "fixture-token".to_owned(),
27203 NodeId::new("node-1").unwrap(),
27204 vec![workspace],
27205 vec![agent("claude")],
27206 );
27207 let instance_id = AgentInstanceId(17);
27208 let original = SessionAddress {
27209 workspace_id,
27210 session: SessionKey {
27211 instance_id,
27212 generation: SessionGeneration(3),
27213 },
27214 };
27215 shared.bind_session(
27216 instance_id,
27217 original.workspace_id.clone(),
27218 original.session.generation,
27219 );
27220 shared
27221 .arm_resume(
27222 &original,
27223 CommandId(41),
27224 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27225 )
27226 .unwrap();
27227
27228 shared.publish_control(ControlEvent {
27229 sequence: 1,
27230 command_id: None,
27231 instance_id,
27232 generation: SessionGeneration(4),
27233 event: ControlEventKind::Running { process_id: None },
27234 });
27235 assert_eq!(
27236 shared.address_for(instance_id, SessionGeneration(3)),
27237 Some(original.clone()),
27238 );
27239 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27240
27241 shared.publish_control(ControlEvent {
27242 sequence: 2,
27243 command_id: Some(CommandId(42)),
27244 instance_id,
27245 generation: SessionGeneration(3),
27246 event: ControlEventKind::CommandRejected {
27247 message: "unrelated command".to_owned(),
27248 },
27249 });
27250
27251 shared.publish_control(ControlEvent {
27252 sequence: 3,
27253 command_id: None,
27254 instance_id,
27255 generation: SessionGeneration(4),
27256 event: ControlEventKind::ResumeAuthorized {
27257 session: gate4agent_types::ResumeSessionSummary {
27258 key: gate4agent_types::ProviderSessionKey::SessionId,
27259 id: "fixture-session".to_owned(),
27260 },
27261 },
27262 });
27263 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27264 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27265 assert_eq!(rebound.workspace_id, original.workspace_id);
27266 let history = shared
27267 .history
27268 .lock()
27269 .unwrap_or_else(|poisoned| poisoned.into_inner());
27270 assert!(history.events.iter().any(|envelope| matches!(
27271 &envelope.event,
27272 NodeEvent::Control { address, event }
27273 if address == &rebound
27274 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27275 )));
27276 }
27277
27278 #[test]
27279 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27280 for agent in ["claude", "codex", "kimi"] {
27281 assert_eq!(
27282 prompt_framing(&AgentId::new(agent).unwrap()),
27283 PromptFraming::BracketedPaste
27284 );
27285 }
27286 }
27287
27288 fn generous_walk_deadline() -> Instant {
27289 Instant::now() + Duration::from_secs(30)
27290 }
27291
27292 #[test]
27293 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27294 let root = temporary_workspace_root("tree");
27295 std::fs::create_dir_all(root.join("src")).unwrap();
27296 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27297 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27298 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27299 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27300 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27301 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27307 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27308 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27312 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27313
27314 let (entries, truncated, _budget) =
27315 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27316 assert!(!truncated);
27317 assert!(entries.iter().any(|entry| {
27318 entry.relative_path.as_utf8() == Some("src")
27319 && entry.kind == WorkspaceEntryKind::Directory
27320 }));
27321 assert!(entries.iter().any(|entry| {
27322 entry.relative_path.as_utf8() == Some("src/lib.rs")
27323 && entry.kind == WorkspaceEntryKind::File
27324 }));
27325 assert!(entries.iter().any(|entry| {
27326 entry.relative_path.as_utf8() == Some("target-notes")
27327 && entry.kind == WorkspaceEntryKind::Directory
27328 }));
27329 assert!(entries.iter().any(|entry| {
27330 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27331 && entry.kind == WorkspaceEntryKind::File
27332 }));
27333 assert!(entries.iter().all(|entry| {
27334 let path = entry.relative_path.as_utf8().unwrap();
27335 !path.starts_with(".git")
27336 && !(path == "target" || path.starts_with("target/"))
27337 && !(path == "target-a2" || path.starts_with("target-a2/"))
27338 && !path.starts_with("node_modules")
27339 }));
27340
27341 std::fs::remove_dir_all(&root).unwrap();
27342 }
27343
27344 #[test]
27345 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27346 let root = temporary_workspace_root("tree-entry-cap");
27347 std::fs::create_dir_all(&root).unwrap();
27348 for index in 0..20 {
27349 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27350 }
27351
27352 let (entries, truncated, budget) =
27353 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27354 assert!(truncated);
27355 assert!(budget.entry_cap_exceeded);
27356 assert!(!budget.time_budget_exceeded);
27357 assert!(entries.len() <= 5);
27358
27359 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27360 .expect("an entry-cap-truncated walk must report a truncation marker");
27361 assert!(inspection_truncation.walk_entry_cap_exceeded);
27362 assert!(!inspection_truncation.walk_time_budget_exceeded);
27363 assert!(!inspection_truncation.git_time_budget_exceeded);
27364 assert!(inspection_truncation.entries_visited >= 5);
27365
27366 std::fs::remove_dir_all(&root).unwrap();
27367 }
27368
27369 #[test]
27370 fn workspace_walk_within_budget_reports_no_truncation() {
27371 let root = temporary_workspace_root("tree-no-truncation");
27372 std::fs::create_dir_all(&root).unwrap();
27373 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27374
27375 let (_entries, truncated, budget) =
27376 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27377 assert!(!truncated);
27378 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27379
27380 std::fs::remove_dir_all(&root).unwrap();
27381 }
27382
27383 #[test]
27384 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27385 let root = temporary_workspace_root("tree-prefix-siblings");
27386 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27387 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27388 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27389 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27390
27391 let (entries, truncated, _budget) =
27392 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27393 assert!(!truncated);
27394 let paths = entries
27395 .iter()
27396 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27397 .collect::<Vec<_>>();
27398 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27399 assert_eq!(
27400 paths,
27401 vec![
27402 "gate4agent-c2".to_owned(),
27403 "gate4agent-c2-client".to_owned(),
27404 "gate4agent-c2-client/lib.rs".to_owned(),
27405 "gate4agent-c2/src".to_owned(),
27406 "gate4agent-c2/src/lib.rs".to_owned(),
27407 ],
27408 );
27409
27410 std::fs::remove_dir_all(&root).unwrap();
27411 }
27412
27413 #[test]
27414 fn git_parsers_cap_status_and_commit_results() {
27415 let mut status_output = Vec::new();
27416 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27417 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27418 }
27419 let mut snapshot = git_snapshot_for_parser();
27420 parse_git_status(&status_output, &mut snapshot);
27421 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27422 assert!(snapshot.truncated);
27423 assert_eq!(snapshot.status[0].index_status, " ");
27424 assert_eq!(snapshot.status[0].worktree_status, "M");
27425
27426 snapshot.truncated = false;
27427 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27428 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27429 .collect::<String>();
27430 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27431 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27432 assert!(snapshot.truncated);
27433 }
27434
27435 #[test]
27436 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27437 let mut snapshot = git_snapshot_for_parser();
27438 parse_git_status(
27439 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27440 &mut snapshot,
27441 );
27442
27443 assert!(!snapshot.truncated);
27444 assert_eq!(snapshot.status.len(), 3);
27445 assert_eq!(snapshot.status[0].index_status, "R");
27446 assert_eq!(snapshot.status[0].worktree_status, " ");
27447 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27448 assert_eq!(
27449 snapshot.status[0]
27450 .previous_path
27451 .as_ref()
27452 .and_then(RepositoryPath::as_utf8),
27453 Some("src/previous name.rs"),
27454 );
27455 assert_eq!(snapshot.status[1].index_status, " ");
27456 assert_eq!(snapshot.status[1].worktree_status, "C");
27457 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27458 assert_eq!(
27459 snapshot.status[1]
27460 .previous_path
27461 .as_ref()
27462 .and_then(RepositoryPath::as_utf8),
27463 Some("src/original.rs"),
27464 );
27465 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27466 assert!(snapshot.status[2].previous_path.is_none());
27467 }
27468
27469 #[test]
27470 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27471 let mut snapshot = git_snapshot_for_parser();
27472 parse_git_status(
27473 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27474 &mut snapshot,
27475 );
27476
27477 assert!(snapshot.truncated);
27478 assert_eq!(snapshot.status.len(), 1);
27479 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27480 assert!(snapshot.status[0].previous_path.is_none());
27481 }
27482
27483 #[tokio::test]
27484 async fn observer_can_browse_host_directories_without_controller() {
27485 let root = temporary_workspace_root("host-directory-observer");
27486 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27487 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27488 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27489 let workspace_id = WorkspaceId::new("primary").unwrap();
27490 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27491 let catalog = active_registry().unwrap();
27492 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27493 let shared = NodeShared::new(
27494 handle,
27495 "fixture-token".to_owned(),
27496 NodeId::new("node-1").unwrap(),
27497 vec![workspace],
27498 vec![agent("claude")],
27499 );
27500 let request = NodeRequest::BrowseHostDirectories {
27501 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27502 after: None,
27503 };
27504 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27505 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27506 }));
27507 assert!(!request_uses_unnegotiated_capability(
27508 &request,
27509 &baseline_capabilities().unwrap(),
27510 ));
27511 assert!(request_uses_unnegotiated_capability(&request, &[]));
27512
27513 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27514 .await
27515 .unwrap();
27516 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27517 panic!("host directory browse returned another response");
27518 };
27519 assert_eq!(
27520 listing
27521 .entries
27522 .iter()
27523 .map(|entry| entry.display_name.as_str())
27524 .collect::<Vec<_>>(),
27525 vec!["directory-a", "directory-b"],
27526 );
27527 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27528 assert!(!listing.incomplete);
27529 std::fs::remove_dir_all(root).unwrap();
27530 }
27531
27532 #[tokio::test]
27533 async fn observer_can_inspect_only_a_registered_workspace() {
27534 let root = temporary_workspace_root("observer");
27535 std::fs::create_dir_all(root.join("src")).unwrap();
27536 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27537 let workspace_id = WorkspaceId::new("primary").unwrap();
27538 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27539 let catalog = active_registry().unwrap();
27540 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27541 let shared = NodeShared::new(
27542 handle,
27543 "fixture-token".to_owned(),
27544 NodeId::new("node-1").unwrap(),
27545 vec![workspace],
27546 vec![agent("claude")],
27547 );
27548
27549 let response = process_request_inner(
27550 &shared,
27551 77,
27552 ClientRole::Observer,
27553 NodeRequest::InspectWorkspace {
27554 workspace_id: workspace_id.clone(),
27555 },
27556 )
27557 .await
27558 .unwrap();
27559 let NodeResponse::WorkspaceInspected { inspection } = response else {
27560 panic!("workspace inspection returned another response");
27561 };
27562 assert_eq!(inspection.workspace_id, workspace_id);
27563 assert!(inspection.entries.iter().any(|entry| {
27564 entry.relative_path.as_utf8() == Some("src/main.rs")
27565 }));
27566
27567 let unknown = process_request_inner(
27568 &shared,
27569 77,
27570 ClientRole::Observer,
27571 NodeRequest::InspectWorkspace {
27572 workspace_id: WorkspaceId::new("unknown").unwrap(),
27573 },
27574 )
27575 .await
27576 .unwrap_err();
27577 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27578 std::fs::remove_dir_all(&root).unwrap();
27579 }
27580
27581 #[test]
27582 fn context_pack_source_accepts_only_running_or_successful_exit() {
27583 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27584 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27585 exit_code: Some(0),
27586 }));
27587 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27588 exit_code: Some(1),
27589 }));
27590 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27591 exit_code: None,
27592 }));
27593 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27594 message: "provider failed".to_owned(),
27595 }));
27596 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27597 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27598 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27599 }
27600
27601 #[test]
27602 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27603 let git = git_snapshot_for_parser();
27604 let first = ContextPackRepositoryObservation {
27605 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27606 git: git.clone(),
27607 files: Vec::new(),
27608 };
27609 let second = ContextPackRepositoryObservation {
27610 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27611 git,
27612 files: Vec::new(),
27613 };
27614
27615 let error = stable_context_pack_repository(first, second).unwrap_err();
27616
27617 assert_eq!(
27618 error.code,
27619 NodeFailureCode::ContextPackMaterializationFailed,
27620 );
27621 }
27622
27623 #[test]
27624 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27625 let git = git_snapshot_for_parser();
27626 let first = ContextPackRepositoryObservation {
27627 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27628 git: git.clone(),
27629 files: vec![ContextPackRepositoryFileSource::utf8(
27630 "README.md",
27631 "first\n".to_owned(),
27632 6,
27633 )],
27634 };
27635 let second = ContextPackRepositoryObservation {
27636 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27637 git,
27638 files: vec![ContextPackRepositoryFileSource::utf8(
27639 "README.md",
27640 "other\n".to_owned(),
27641 6,
27642 )],
27643 };
27644
27645 let error = stable_context_pack_repository(first, second).unwrap_err();
27646
27647 assert_eq!(
27648 error.code,
27649 NodeFailureCode::ContextPackMaterializationFailed,
27650 );
27651 }
27652
27653 #[tokio::test]
27654 async fn native_session_catalog_zero_active_session_fixture() {
27655 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27656 let history_root = temporary_workspace_root("native-catalog-history");
27657 let outside_root = temporary_workspace_root("native-catalog-outside");
27658 std::fs::create_dir_all(&workspace_root).unwrap();
27659 std::fs::create_dir_all(&history_root).unwrap();
27660 std::fs::create_dir_all(&outside_root).unwrap();
27661 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27662 [
27663 serde_json::json!({
27664 "type": "user",
27665 "sessionId": session_id,
27666 "cwd": cwd.to_str().unwrap(),
27667 "message": { "content": "raw user text must not cross the catalog wire" }
27668 }),
27669 serde_json::json!({
27670 "type": "assistant",
27671 "sessionId": session_id,
27672 "cwd": cwd.to_str().unwrap(),
27673 "message": { "model": "claude-sonnet", "content": "raw answer" }
27674 }),
27675 serde_json::json!({
27676 "type": "ai-title",
27677 "sessionId": session_id,
27678 "aiTitle": title
27679 }),
27680 ]
27681 .into_iter()
27682 .map(|record| record.to_string())
27683 .collect::<Vec<_>>()
27684 .join("\n")
27685 };
27686 std::fs::write(
27687 history_root.join("stable-session.jsonl"),
27688 transcript("stable-session", &workspace_root, "Workspace review"),
27689 )
27690 .unwrap();
27691 std::fs::write(
27692 history_root.join("outside-session.jsonl"),
27693 transcript("outside-session", &outside_root, "Outside review"),
27694 )
27695 .unwrap();
27696 let history = NativeHistoryConfig::new(vec![
27697 NativeHistoryRoot::new(
27698 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27699 HistorySourceLayout::SingleNdjson,
27700 history_root.clone(),
27701 )
27702 .unwrap(),
27703 ])
27704 .unwrap();
27705 let catalog = active_registry().unwrap();
27706 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27707 assert!(handle.snapshot().sessions.is_empty());
27708 let workspace_id = WorkspaceId::new("primary").unwrap();
27709 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27710 let mut shared = NodeShared::new(
27711 handle,
27712 "fixture-token".to_owned(),
27713 NodeId::new("node-native-catalog").unwrap(),
27714 vec![workspace],
27715 vec![agent("claude")],
27716 );
27717 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27718 NativeSessionCatalogAuthority::new(history),
27719 )));
27720 let route = NativeSessionCatalogRoute::workspace(
27721 workspace_id.clone(),
27722 agent("claude"),
27723 );
27724 let request = NodeRequest::CatalogNativeSessions {
27725 route: route.clone(),
27726 limit: 2,
27727 };
27728 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27729 .await
27730 .unwrap_err();
27731 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27732 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27733 .await
27734 .unwrap();
27735 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27736 panic!("native catalog returned a different response");
27737 };
27738 let summary = summary.expect("node must emit native session catalog summary metadata");
27739 assert_eq!(entries.len(), 1);
27740 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27741 assert_eq!(entries[0].external_group, None);
27742 assert_eq!(entries[0].record_id, None);
27743 assert_eq!(entries[0].title, None);
27744 assert_eq!(entries[0].model, None);
27745 assert_eq!(entries[0].message_count, 0);
27746 let encoded_entries = serde_json::to_string(&entries).unwrap();
27747 assert!(!encoded_entries.contains("stable-session"));
27748 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27749 let page_request = NodeRequest::PageNativeSessions {
27750 route: route.clone(),
27751 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27752 catalog_revision: summary.catalog_revision,
27753 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27754 after_selection_id: None,
27755 limit: 2,
27756 };
27757 let observer = process_request_inner(
27758 &shared,
27759 7,
27760 ClientRole::Observer,
27761 page_request.clone(),
27762 )
27763 .await
27764 .unwrap_err();
27765 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27766 let page_response = process_request_inner(
27767 &shared,
27768 7,
27769 ClientRole::Operator,
27770 page_request.clone(),
27771 )
27772 .await
27773 .unwrap();
27774 assert!(matches!(
27775 page_response,
27776 NodeResponse::NativeSessionsPaged { page, .. }
27777 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27778 ));
27779 let stale = process_request_inner(
27780 &shared,
27781 7,
27782 ClientRole::Operator,
27783 NodeRequest::PageNativeSessions {
27784 route: route.clone(),
27785 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27786 catalog_revision: summary.catalog_revision.wrapping_add(1),
27787 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27788 after_selection_id: None,
27789 limit: 2,
27790 },
27791 )
27792 .await
27793 .unwrap_err();
27794 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27795 let selection = NativeSessionSelection {
27796 route: route.clone(),
27797 catalog_revision: summary.catalog_revision,
27798 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27799 selection_id: entries[0].selection_id.clone(),
27800 };
27801 let preview_response = process_request_inner(
27802 &shared,
27803 7,
27804 ClientRole::Operator,
27805 NodeRequest::PreviewNativeSession {
27806 selection: selection.clone(),
27807 message_limit: 1,
27808 },
27809 )
27810 .await
27811 .unwrap();
27812 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27813 panic!("native preview returned a different response");
27814 };
27815 assert_eq!(preview.messages.len(), 1);
27816 assert!(preview.message_count_exact);
27817 assert_eq!(preview.messages[0].text, "raw answer");
27818 assert!(preview.truncated);
27819
27820 let index_request = NodeRequest::IndexNativeSession {
27821 selection: selection.clone(),
27822 display_name: "Saved Claude".to_owned(),
27823 };
27824 let observer = process_request_inner(
27825 &shared,
27826 7,
27827 ClientRole::Observer,
27828 index_request.clone(),
27829 )
27830 .await
27831 .unwrap_err();
27832 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27833 let without_controller = process_request_inner(
27834 &shared,
27835 7,
27836 ClientRole::Operator,
27837 index_request.clone(),
27838 )
27839 .await
27840 .unwrap_err();
27841 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27842 let acquired = process_request_inner(
27843 &shared,
27844 7,
27845 ClientRole::Operator,
27846 NodeRequest::AcquireController { lease_ms: 5_000 },
27847 )
27848 .await
27849 .unwrap();
27850 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27851 let indexed = process_request_inner(
27852 &shared,
27853 7,
27854 ClientRole::Operator,
27855 index_request,
27856 )
27857 .await
27858 .unwrap();
27859 assert!(indexed.native_session_index_contract_is_valid());
27860 let NodeResponse::NativeSessionIndexed {
27861 selection: echoed_selection,
27862 record,
27863 } = indexed else {
27864 panic!("native session index returned a different response");
27865 };
27866 assert_eq!(echoed_selection, selection);
27867 let recataloged = process_request_inner(
27868 &shared,
27869 7,
27870 ClientRole::Operator,
27871 NodeRequest::CatalogNativeSessions {
27872 route,
27873 limit: 2,
27874 },
27875 )
27876 .await
27877 .unwrap();
27878 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27879 panic!("native catalog returned a different response after indexing");
27880 };
27881 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27882
27883 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27884 let external_catalog = process_request_inner(
27885 &shared,
27886 7,
27887 ClientRole::Operator,
27888 NodeRequest::CatalogNativeSessions {
27889 route: unregistered_route.clone(),
27890 limit: 2,
27891 },
27892 )
27893 .await
27894 .unwrap();
27895 let external_json = serde_json::to_string(&external_catalog).unwrap();
27896 assert!(!external_json.contains("outside-session"));
27897 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27898 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27899 assert!(!external_json.contains("\"session_id\""));
27900 assert!(!external_json.contains("\"cwd\""));
27901 assert!(!external_json.contains("\"path\""));
27902 let NodeResponse::NativeSessionsCataloged {
27903 route: echoed_route,
27904 entries: external_entries,
27905 summary: Some(external_summary),
27906 } = external_catalog
27907 else {
27908 panic!("unregistered native catalog returned a different response");
27909 };
27910 assert_eq!(echoed_route, unregistered_route);
27911 assert_eq!(external_entries.len(), 1);
27912 assert_eq!(external_entries[0].record_id, None);
27913 let external_group = external_entries[0]
27914 .external_group
27915 .as_ref()
27916 .expect("unregistered session must have an external group");
27917 assert_eq!(
27918 external_group.kind,
27919 gate4agent_types::NativeSessionExternalGroupKind::Project,
27920 );
27921 assert!(external_group.group_id.starts_with("external-"));
27922 assert_eq!(
27923 external_group.display_name,
27924 outside_root.file_name().unwrap().to_string_lossy(),
27925 );
27926 let external_selection = NativeSessionSelection {
27927 route: unregistered_route,
27928 catalog_revision: external_summary.catalog_revision,
27929 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27930 selection_id: external_entries[0].selection_id.clone(),
27931 };
27932 let external_preview = process_request_inner(
27933 &shared,
27934 7,
27935 ClientRole::Operator,
27936 NodeRequest::PreviewNativeSession {
27937 selection: external_selection.clone(),
27938 message_limit: 1,
27939 },
27940 )
27941 .await
27942 .unwrap();
27943 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27944 assert!(!external_preview_json.contains("outside-session"));
27945 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27946 assert!(!external_preview_json.contains("\"session_id\""));
27947 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27948 panic!("unregistered native preview returned a different response");
27949 };
27950 assert_eq!(preview.messages.len(), 1);
27951 let external_index = process_request_inner(
27952 &shared,
27953 7,
27954 ClientRole::Operator,
27955 NodeRequest::IndexNativeSession {
27956 selection: external_selection,
27957 display_name: "External Claude".to_owned(),
27958 },
27959 )
27960 .await
27961 .unwrap_err();
27962 assert_eq!(
27963 external_index.code,
27964 NodeFailureCode::WorkspaceRegistrationRequired,
27965 );
27966 let record_response = process_request_inner(
27967 &shared,
27968 7,
27969 ClientRole::Operator,
27970 NodeRequest::PreviewSessionRecord {
27971 record_id: record.record_id.clone(),
27972 message_limit: 2,
27973 },
27974 )
27975 .await
27976 .unwrap();
27977 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27978 panic!("record preview returned a different response");
27979 };
27980 assert_eq!(record_id, record.record_id);
27981 assert_eq!(preview.messages.len(), 2);
27982 let encoded = serde_json::to_string(&preview).unwrap();
27983 assert!(!encoded.contains("stable-session"));
27984 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27985 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27986 panic!("record preview history summary returned a different response");
27987 };
27988 let summaries = events
27989 .iter()
27990 .filter_map(|envelope| match &envelope.event {
27991 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27992 if record_id == &record.record_id =>
27993 {
27994 Some(*summary)
27995 }
27996 _ => None,
27997 })
27998 .collect::<Vec<_>>();
27999 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28000 let summary_json = serde_json::to_string(&summaries).unwrap();
28001 for private in [
28002 "stable-session",
28003 "raw user text must not cross the catalog wire",
28004 "raw answer",
28005 "Workspace review",
28006 "claude-sonnet",
28007 ] {
28008 assert!(!summary_json.contains(private), "{private}");
28009 }
28010 let identity = record.provider_session.clone().unwrap();
28011 assert_eq!(
28012 shared.revalidate_session_record_preview(&record, &identity),
28013 Ok(())
28014 );
28015 shared.remove_record_memory(&record.record_id);
28016 assert_eq!(
28017 shared
28018 .revalidate_session_record_preview(&record, &identity)
28019 .unwrap_err()
28020 .code,
28021 NodeFailureCode::UnknownSessionRecord
28022 );
28023 let released = process_request_inner(
28024 &shared,
28025 7,
28026 ClientRole::Operator,
28027 NodeRequest::ReleaseController,
28028 )
28029 .await
28030 .unwrap();
28031 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28032 assert!(shared.controller_state().is_none());
28033 std::fs::remove_dir_all(workspace_root).unwrap();
28034 std::fs::remove_dir_all(history_root).unwrap();
28035 std::fs::remove_dir_all(outside_root).unwrap();
28036 }
28037
28038 #[tokio::test]
28039 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28040 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28041 std::fs::create_dir_all(&history_root).unwrap();
28042 let history = NativeHistoryConfig::new(vec![
28043 NativeHistoryRoot::new(
28044 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28045 HistorySourceLayout::SingleNdjson,
28046 history_root.clone(),
28047 )
28048 .unwrap(),
28049 ])
28050 .unwrap();
28051 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28052 let held = catalog.lock().unwrap();
28053 let result = timeout(
28054 Duration::from_secs(1),
28055 run_native_session_catalog_operation(
28056 Arc::clone(&catalog),
28057 "native session catalog",
28058 |_| Ok::<(), std::convert::Infallible>(()),
28059 ),
28060 )
28061 .await
28062 .expect("concurrent native catalog request must not wait for the operation deadline")
28063 .unwrap_err();
28064 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28065 drop(held);
28066 std::fs::remove_dir_all(history_root).unwrap();
28067 }
28068
28069 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28070 let unique = std::time::SystemTime::now()
28071 .duration_since(std::time::UNIX_EPOCH)
28072 .unwrap()
28073 .as_nanos();
28074 std::env::temp_dir().join(format!(
28075 "gate4agent-node-{label}-{}-{unique}",
28076 std::process::id(),
28077 ))
28078 }
28079
28080 fn git_snapshot_for_parser() -> GitSnapshot {
28081 GitSnapshot {
28082 is_repository: true,
28083 branch: Some("main".to_owned()),
28084 status: Vec::new(),
28085 recent_commits: Vec::new(),
28086 worktrees: Vec::new(),
28087 managed_worktree: None,
28088 truncated: false,
28089 diagnostic: None,
28090 }
28091 }
28092
28093 #[tokio::test]
28094 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28095 let root = temporary_workspace_root("managed-git-scope");
28096 std::fs::create_dir_all(&root).unwrap();
28097 run_test_git(&root, &["init"]);
28098 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28099 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28100 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28101 run_test_git(&root, &["add", "seed.txt"]);
28102 run_test_git(&root, &["commit", "-m", "seed"]);
28103 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28104
28105 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28106 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28107 let catalog = active_registry().unwrap();
28108 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28109 let shared = NodeShared::new(
28110 handle,
28111 "fixture-token".to_owned(),
28112 NodeId::new("node-1").unwrap(),
28113 vec![workspace],
28114 vec![agent("claude")],
28115 );
28116 let mut lease = ManagedWorktreeLeaseRecord {
28117 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28118 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28119 workspace_id: workspace_id.clone(),
28120 profile_id: WorktreeProfileId::new("default").unwrap(),
28121 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28122 target_root: root.to_string_lossy().into_owned(),
28123 branch: "gate4agent/a".to_owned(),
28124 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28125 expected_head: None,
28126 retention: ManagedWorktreeRetention::Retain,
28127 state: ManagedWorktreeLeaseState::InUse,
28128 session_holders: vec![ManagedWorktreeSessionHolder {
28129 incarnation_id: shared.incarnation_id,
28130 instance_id: AgentInstanceId(7),
28131 generation: SessionGeneration(1),
28132 }],
28133 record_holders: Vec::new(),
28134 cleanup_failure: None,
28135 created_at_unix_ms: 1,
28136 updated_at_unix_ms: 1,
28137 };
28138 *shared
28139 .managed_worktrees
28140 .lock()
28141 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28142 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28143
28144 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28145 let scope = inspection.git.managed_worktree.unwrap();
28146 assert_eq!(scope.lease_id, lease.lease_id);
28147 assert_eq!(scope.branch, "gate4agent/a");
28148 assert_eq!(scope.active_session_count, 1);
28149
28150 lease.branch = "gate4agent/b".to_owned();
28151 *shared
28152 .managed_worktrees
28153 .lock()
28154 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28155 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28156 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28157 assert!(mismatch.git.managed_worktree.is_none());
28158 std::fs::remove_dir_all(root).unwrap();
28159 }
28160
28161 #[tokio::test]
28162 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28163 let root = temporary_workspace_root("workspace-file-write-controller");
28164 std::fs::create_dir_all(root.join("src")).unwrap();
28165 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28166 let workspace_id = WorkspaceId::new("primary").unwrap();
28167 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28168 let catalog = active_registry().unwrap();
28169 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28170 let shared = NodeShared::new(
28171 handle,
28172 "fixture-token".to_owned(),
28173 NodeId::new("node-1").unwrap(),
28174 vec![workspace],
28175 vec![agent("claude")],
28176 );
28177 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28178 let expected_revision = workspace_file_revision(b"old\n");
28179 let request = NodeRequest::WriteWorkspaceFile {
28180 workspace_id: workspace_id.clone(),
28181 path: path.clone(),
28182 expected_revision: expected_revision.clone(),
28183 text: "new\n".to_owned(),
28184 };
28185 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28186 .await
28187 .unwrap_err();
28188 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28189 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28190 .await
28191 .unwrap_err();
28192 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28193 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28194 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28195 let NodeResponse::WorkspaceFileWritten { file } = response else {
28196 panic!("workspace file write returned another response");
28197 };
28198 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28199 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28200 let stale = process_request_inner(
28201 &shared,
28202 77,
28203 ClientRole::Operator,
28204 NodeRequest::WriteWorkspaceFile {
28205 workspace_id,
28206 path,
28207 expected_revision,
28208 text: "lost update\n".to_owned(),
28209 },
28210 )
28211 .await
28212 .unwrap_err();
28213 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28214 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28215 std::fs::remove_dir_all(root).unwrap();
28216 }
28217
28218 #[tokio::test]
28219 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28220 let root = temporary_workspace_root("workspace-entry-create-controller");
28221 std::fs::create_dir_all(root.join("src")).unwrap();
28222 let workspace_id = WorkspaceId::new("primary").unwrap();
28223 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28224 let catalog = active_registry().unwrap();
28225 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28226 let shared = NodeShared::new(
28227 handle,
28228 "fixture-token".to_owned(),
28229 NodeId::new("node-1").unwrap(),
28230 vec![workspace],
28231 vec![agent("claude")],
28232 );
28233 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28234 let request = NodeRequest::CreateWorkspaceFile {
28235 workspace_id: workspace_id.clone(),
28236 path: path.clone(),
28237 };
28238 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28239 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28240 }));
28241 assert!(!request_uses_unnegotiated_capability(
28242 &request,
28243 &baseline_capabilities().unwrap(),
28244 ));
28245 assert!(request_uses_unnegotiated_capability(&request, &[]));
28246 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28247 .await
28248 .unwrap_err();
28249 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28250 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28251 .await
28252 .unwrap_err();
28253 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28254 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28255
28256 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28257 .await
28258 .unwrap();
28259 let NodeResponse::WorkspaceFileCreated { file } = response else {
28260 panic!("workspace file create returned another response");
28261 };
28262 assert_eq!(file.workspace_id, workspace_id);
28263 assert_eq!(file.path, path);
28264 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28265 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28266
28267 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28268 .await
28269 .unwrap_err();
28270 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28271
28272 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28273 let response = process_request_inner(
28274 &shared,
28275 77,
28276 ClientRole::Operator,
28277 NodeRequest::CreateWorkspaceDirectory {
28278 workspace_id: workspace_id.clone(),
28279 path: directory_path.clone(),
28280 },
28281 )
28282 .await
28283 .unwrap();
28284 assert_eq!(
28285 response,
28286 NodeResponse::WorkspaceDirectoryCreated {
28287 workspace_id: workspace_id.clone(),
28288 entry: WorkspaceEntry {
28289 relative_path: directory_path,
28290 kind: WorkspaceEntryKind::Directory,
28291 },
28292 },
28293 );
28294 assert!(root.join("src/nested").is_dir());
28295
28296 let missing_parent = process_request_inner(
28297 &shared,
28298 77,
28299 ClientRole::Operator,
28300 NodeRequest::CreateWorkspaceFile {
28301 workspace_id: workspace_id.clone(),
28302 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28303 },
28304 )
28305 .await
28306 .unwrap_err();
28307 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28308 let non_directory_parent = process_request_inner(
28309 &shared,
28310 77,
28311 ClientRole::Operator,
28312 NodeRequest::CreateWorkspaceFile {
28313 workspace_id,
28314 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28315 },
28316 )
28317 .await
28318 .unwrap_err();
28319 assert_eq!(
28320 non_directory_parent.code,
28321 NodeFailureCode::RepositoryParentNotDirectory,
28322 );
28323 std::fs::remove_dir_all(root).unwrap();
28324 }
28325
28326 #[tokio::test]
28327 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28328 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28329 std::fs::create_dir_all(root.join("src")).unwrap();
28330 #[cfg(windows)]
28331 let root = std::fs::canonicalize(root).unwrap();
28332 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28333 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28334 let barrier = Arc::new(std::sync::Barrier::new(2));
28335 let done = Arc::new(AtomicBool::new(false));
28336 let task_commit_state = Arc::clone(&commit_state);
28337 let task_barrier = Arc::clone(&barrier);
28338 let task_done = Arc::clone(&done);
28339 let task_root = root.clone();
28340 let task = tokio::task::spawn_blocking(move || {
28341 task_barrier.wait();
28342 let result = create_workspace_file_on_disk(
28343 &task_root,
28344 &path,
28345 &task_commit_state,
28346 );
28347 task_done.store(true, Ordering::Release);
28348 result
28349 });
28350
28351 let error = settle_workspace_entry_create(
28352 task,
28353 Arc::clone(&commit_state),
28354 Duration::from_millis(10),
28355 "fixture deadline",
28356 "fixture task failed",
28357 )
28358 .await
28359 .unwrap_err();
28360 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28361 assert_eq!(
28362 commit_state.load(Ordering::Acquire),
28363 WORKSPACE_ENTRY_CREATE_CANCELED,
28364 );
28365
28366 barrier.wait();
28367 timeout(Duration::from_secs(1), async {
28368 while !done.load(Ordering::Acquire) {
28369 tokio::task::yield_now().await;
28370 }
28371 })
28372 .await
28373 .expect("canceled blocking create did not settle");
28374 assert!(!root.join("src/late.rs").exists());
28375 std::fs::remove_dir_all(root).unwrap();
28376 }
28377}
28378
28379#[cfg(test)]
28380mod provisional_spawn_tests {
28381 use super::*;
28382 use std::sync::Arc;
28383
28384 fn shared() -> NodeShared {
28385 let catalog = active_registry().unwrap();
28386 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28387 let workspace = WorkspaceConfig::new(
28388 WorkspaceId::new("primary").unwrap(),
28389 std::env::current_dir().unwrap(),
28390 )
28391 .unwrap()
28392 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28393 NodeShared::new(
28394 handle,
28395 "fixture-token".to_owned(),
28396 NodeId::new("node-provisional-test").unwrap(),
28397 vec![workspace],
28398 vec![AgentId::new("claude").unwrap()],
28399 )
28400 }
28401
28402 #[test]
28405 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28406 let shared = shared();
28407 let ws = WorkspaceId::new("primary").unwrap();
28408 let mut ids = Vec::with_capacity(50);
28409 for i in 1u64..=50 {
28410 let id = AgentInstanceId(i);
28411 ids.push(id);
28412 let address = SessionAddress {
28413 workspace_id: ws.clone(),
28414 session: SessionKey {
28415 instance_id: id,
28416 generation: SessionGeneration(1),
28417 },
28418 };
28419 shared.mark_spawn_provisional(address);
28420 }
28421 assert_eq!(
28422 shared
28423 .provisional_spawns
28424 .lock()
28425 .unwrap_or_else(|p| p.into_inner())
28426 .len(),
28427 50
28428 );
28429 for id in ids.iter().step_by(2) {
28430 shared.sessionize_spawn(*id);
28431 }
28432 assert_eq!(
28433 shared
28434 .provisional_spawns
28435 .lock()
28436 .unwrap_or_else(|p| p.into_inner())
28437 .len(),
28438 25
28439 );
28440 assert!(shared.take_due_provisional_spawns().is_empty());
28441 {
28442 let mut map = shared
28443 .provisional_spawns
28444 .lock()
28445 .unwrap_or_else(|p| p.into_inner());
28446 let past = Instant::now() - Duration::from_secs(91);
28447 for (_, (_, at)) in map.iter_mut() {
28448 *at = past;
28449 }
28450 }
28451 let due = shared.take_due_provisional_spawns();
28452 assert_eq!(due.len(), 25);
28453 let due_ids: std::collections::BTreeSet<_> =
28454 due.iter().map(|a| a.session.instance_id.0).collect();
28455 let expected: std::collections::BTreeSet<_> =
28456 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28457 assert_eq!(due_ids, expected);
28458 assert!(shared
28459 .provisional_spawns
28460 .lock()
28461 .unwrap_or_else(|p| p.into_inner())
28462 .is_empty());
28463 }
28464
28465 #[test]
28468 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28469 let shared = Arc::new(shared());
28470 let ws = WorkspaceId::new("primary").unwrap();
28471 let mut handles = Vec::new();
28472 for i in 1u64..=20 {
28473 let shared = Arc::clone(&shared);
28474 let ws = ws.clone();
28475 handles.push(std::thread::spawn(move || {
28476 let id = AgentInstanceId(i);
28477 let address = SessionAddress {
28478 workspace_id: ws,
28479 session: SessionKey {
28480 instance_id: id,
28481 generation: SessionGeneration(1),
28482 },
28483 };
28484 shared.mark_spawn_provisional(address);
28485 id
28486 }));
28487 }
28488 let mut ids = Vec::new();
28489 for h in handles {
28490 ids.push(h.join().unwrap());
28491 }
28492 ids.sort_by_key(|id| id.0);
28493 assert_eq!(ids.len(), 20);
28494 ids.dedup();
28495 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28496 assert_eq!(
28497 shared
28498 .provisional_spawns
28499 .lock()
28500 .unwrap_or_else(|p| p.into_inner())
28501 .len(),
28502 20
28503 );
28504 shared.sessionize_spawn(AgentInstanceId(7));
28505 let left: Vec<_> = shared
28506 .provisional_spawns
28507 .lock()
28508 .unwrap_or_else(|p| p.into_inner())
28509 .keys()
28510 .map(|id| id.0)
28511 .collect();
28512 assert!(!left.contains(&7));
28513 assert_eq!(left.len(), 19);
28514 }
28515}
28516
28517
28518#[cfg(unix)]
28519extern "C" {
28520 #[link_name = "geteuid"]
28521 fn libc_geteuid() -> u32;
28522}
28523
28524#[cfg(test)]
28525mod call_home_link_tests {
28526 use super::*;
28527
28528 #[test]
28529 fn call_home_backoff_is_capped_and_monotonic() {
28530 let mut last = Duration::ZERO;
28531 for attempt in 1..20 {
28532 let next = call_home_backoff(attempt);
28533 assert!(next >= last);
28534 assert!(next <= Duration::from_secs(8));
28535 last = next;
28536 }
28537 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28538 }
28539
28540 #[tokio::test]
28541 async fn call_home_socket_gets_keepalive() {
28542 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28543 let addr = listener.local_addr().unwrap();
28544 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28545 let client = client.unwrap();
28546 enable_call_home_keepalive(&client).unwrap();
28547 let sock = socket2::SockRef::from(&client);
28548 assert!(sock.keepalive().unwrap());
28549 #[cfg(any(target_os = "linux", target_os = "macos"))]
28550 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28551 }
28552}
28553
28554fn provider_session_exists(provider: &str, id: &str) -> bool {
28556 if provider != "claude" { return false; }
28557 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28558 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28559 let name = format!("{id}.jsonl");
28560 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())))
28561}
28562
28563fn overlay_with_extra_args(
28565 provider: &AgentId,
28566 mode: SessionMode,
28567 overlay: Option<PreparedNativeLaunchOverlay>,
28568 args: Vec<OsString>,
28569) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28570 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28571 Ok(match (overlay, args) {
28572 (prepared, args) if args.is_empty() => prepared,
28573 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28574 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28575 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28576 provider.clone(),
28577 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28578 Vec::new(),
28579 args,
28580 ).map_err(|_| bad())?)),
28581 })
28582}