1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219 static RESUME_PROVIDER_SESSION: Option<String>;
221}
222
223fn spawn_selection(
225 options: &gate4agent_node_protocol::SpawnSessionOptions,
226) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
227 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
228 match (&options.model, &options.effort) {
229 (None, None) => Ok(None),
230 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
231 (Some(model), effort) => {
232 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
233 if let Some(e) = effort {
234 sel = sel.with_value("effort", e.clone());
235 }
236 Ok(Some(sel))
237 }
238 }
239}
240
241mod browser_bridge;
242
243use gate4agent_node_core::drive_loop_profile;
244
245#[path = "bundle_delivery.rs"]
246mod bundle_delivery;
247
248#[path = "context_pack_store.rs"]
249mod context_pack_store;
250
251#[cfg(windows)]
252pub use crate::platform::DEFAULT_NODE_ENDPOINT;
253
254const NODE_EVENT_HISTORY_MAX: usize = 4_096;
255const NODE_BROADCAST_CAPACITY: usize = 1_024;
256const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
257const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
263const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
270const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
271const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
272const MAX_PREAUTH_CONNECTIONS: usize = 32;
273const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
274const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
275const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
276const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
277const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
278const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
284const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
292const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
300const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
307const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
312const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
313const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
314const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
315const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
316const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
317const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
318const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
319const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
320const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
321const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
322const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
323const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
324const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
327const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
334const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
338const GIT_STATUS_MAX_ENTRIES: usize = 128;
339const GIT_COMMIT_MAX_ENTRIES: usize = 12;
340const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
341const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
342const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
343const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
344const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
345const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
346const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
347const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
348const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
349const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
350 "provider-session-identity-allocation-failed";
351const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
352const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
353const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
354const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
355const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
356const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
357const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
358 "durable-state-path-semantics-unsupported";
359
360fn opaque_windows_path(value: String) -> OpaqueHostPath {
361 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
362}
363
364fn agent_progress_from_provider_snapshot(
365 address: SessionAddress,
366 provider: &ProviderSnapshot,
367) -> Option<SessionAgentProgress> {
368 let mut truncated = false;
369 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
370 let mut active_tool_labels = Vec::with_capacity(
371 provider
372 .active_tools
373 .len()
374 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
375 );
376 for tool in &provider.active_tools {
377 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
378 truncated = true;
379 break;
380 }
381 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
382 active_tool_labels.push(label);
383 }
384 }
385 let attention = provider
386 .interactions
387 .iter()
388 .find(|interaction| {
389 matches!(
390 interaction.status,
391 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
392 )
393 })
394 .map(|interaction| AgentProgressAttentionV1 {
395 kind: match interaction.interaction_kind {
396 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
397 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
398 },
399 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
400 });
401 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
402 let usage = (provider.completed_turns > 0
403 || provider.usage.input_tokens > 0
404 || provider.usage.output_tokens > 0
405 || provider.usage.cache_read_tokens > 0
406 || provider.usage.cache_write_tokens > 0
407 || provider.usage.reasoning_tokens > 0)
408 .then_some(AgentProgressUsageV1 {
409 input_tokens: provider.usage.input_tokens,
410 output_tokens: provider.usage.output_tokens,
411 cache_read_tokens: provider.usage.cache_read_tokens,
412 cache_write_tokens: provider.usage.cache_write_tokens,
413 reasoning_tokens: provider.usage.reasoning_tokens,
414 });
415 let progress = AgentProgressV1 {
416 provider_sequence: provider.sequence,
417 activity: provider.activity,
418 completed_turns: provider.completed_turns,
419 usage,
420 current: AgentProgressCurrentV1::from(provider.activity),
421 active_tool_labels,
422 active_tool_count,
423 attention,
424 subagent_count,
425 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
426 gap_count: provider.gap_count,
427 stale: provider.stale,
428 truncated,
429 };
430 let entry = SessionAgentProgress { address, progress };
431 serde_json::to_vec(&entry)
432 .ok()
433 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
434 .map(|_| entry)
435}
436
437fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
438 match u32::try_from(count) {
439 Ok(count) => count,
440 Err(_) => {
441 *truncated = true;
442 u32::MAX
443 }
444 }
445}
446
447fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
448 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
449 return None;
450 }
451 Some(match event {
452 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
453 ProviderEvent::SessionIdentityObserved { .. } => {
454 AgentProgressEventKindV1::SessionIdentityObserved
455 }
456 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
457 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
458 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
459 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
460 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
461 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
462 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
463 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
464 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
465 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
466 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
467 ProviderEvent::InteractionRequested { .. } => {
468 AgentProgressEventKindV1::InteractionRequested
469 }
470 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
471 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
472 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
473 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
474 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
475 ProviderEvent::UnrecognizedNotification { .. } => {
476 AgentProgressEventKindV1::UnrecognizedNotification
477 }
478 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
479 ProviderEvent::UserMessage { .. }
490 | ProviderEvent::Plan { .. }
491 | ProviderEvent::AvailableCommandsUpdated { .. }
492 | ProviderEvent::ModeChanged { .. }
493 | ProviderEvent::SessionInfoUpdated { .. }
494 | ProviderEvent::UsageUpdated { .. }
495 | ProviderEvent::ConfigOptionsUpdated { .. } => {
496 AgentProgressEventKindV1::UnrecognizedNotification
497 }
498 })
499}
500
501fn provider_family_is_live(family: AdapterFamily) -> bool {
514 match family {
515 AdapterFamily::PtySemantic
516 | AdapterFamily::Hook
517 | AdapterFamily::ManagedHook
518 | AdapterFamily::Pipe
519 | AdapterFamily::OneShot
520 | AdapterFamily::Acp => true,
521 AdapterFamily::History
522 | AdapterFamily::Resume
523 | AdapterFamily::SessionOptions
524 | AdapterFamily::CapabilityProbe => false,
525 }
526}
527
528fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
529 match by {
530 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
531 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
532 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
533 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
534 }
535}
536
537fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
549 match authority {
550 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
551 BlockAuthorityV1::HostPolicy => "denied by host policy",
552 BlockAuthorityV1::HostDeadline => {
553 "denied by host policy after the operator deadline expired"
554 }
555 BlockAuthorityV1::Operator => "declined by the operator",
556 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
557 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
558 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
559 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
560 BlockAuthorityV1::UserRejected => "rejected by the user",
561 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
562 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
563 }
564}
565
566fn host_request_denied_block(
569 method: &str,
570 by: ProviderHostDecisionAuthority,
571 reason: Option<&str>,
572) -> (String, BlockAuthorityV1, String) {
573 let authority = block_authority(by);
574 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
575 let (reason_text, _) =
579 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
580 (tool_class_label(method), authority, reason_text)
581}
582
583fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
602 match kind? {
603 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
604 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
605 _ => None,
606 }
607}
608
609fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
621 let trimmed = text.trim();
622 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
623 return (trimmed.to_owned(), None);
624 };
625 let tail = after_reason.trim_start();
626 let Some((reason, help)) = tail.split_once(". ") else {
627 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
631 return (reason.to_owned(), None);
632 };
633 let help = help.trim();
634 if help.is_empty() {
635 (reason.trim().to_owned(), None)
636 } else {
637 (reason.trim().to_owned(), Some(help.to_owned()))
638 }
639}
640
641fn tool_class_from_denial_help(help: Option<&str>) -> String {
651 help.and_then(|text| text.split("add a ").nth(1))
652 .and_then(|tail| tail.split(' ').next())
653 .filter(|word| !word.is_empty())
654 .map(str::to_owned)
655 .unwrap_or_else(|| "Tool".to_owned())
656}
657
658fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
666 fn names_quota(s: &str) -> bool {
667 let lower = s.to_ascii_lowercase();
668 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
669 }
670 vendor_code.is_some_and(names_quota) || names_quota(message)
671}
672
673fn provider_session_end_block(
689 stop_reason: &ProviderStopReason,
690) -> Option<(BlockAuthorityV1, String, String)> {
691 match stop_reason {
692 ProviderStopReason::Refusal => Some((
693 BlockAuthorityV1::ProviderRefusal,
694 "refusal".to_owned(),
695 "the provider ended the turn with stopReason: refusal".to_owned(),
696 )),
697 ProviderStopReason::ProviderError { message, vendor_code, .. }
698 if provider_error_is_quota(vendor_code.as_deref(), message) =>
699 {
700 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
701 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
702 }
703 _ => None,
704 }
705}
706
707fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
748 let ControlEventKind::ProviderEvent {
749 sequence: provider_sequence,
750 source,
751 source_sequence,
752 event: provider_event,
753 } = &event.event
754 else {
755 return None;
756 };
757 if !provider_family_is_live(source.family) {
758 return None;
759 }
760 let kind = match provider_event {
761 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
762 text: text.clone(),
763 is_delta: *is_delta,
764 },
765 ProviderEvent::Thinking { text } => {
766 AgentStreamChunkKindV1::Thinking { text: text.clone() }
767 }
768 ProviderEvent::InteractionRequested {
769 interaction_kind,
770 tool_name,
771 title,
772 prompt,
773 options,
774 ..
775 } => AgentStreamChunkKindV1::InteractionPrompt {
776 correlation_id: interaction_correlation(
777 event.instance_id,
778 event.generation,
779 *provider_sequence,
780 ),
781 interaction_kind: *interaction_kind,
782 tool_name: tool_name.clone(),
783 title: title.clone(),
792 prompt: prompt.clone(),
793 options: options
794 .iter()
795 .map(|option| AgentStreamInteractionOptionV1 {
796 option_id: option.option_id.clone(),
797 name: option.name.clone(),
798 kind: option.kind.clone(),
799 })
800 .collect(),
801 },
802 ProviderEvent::ConfigOptionsUpdated { options } => {
803 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
804 }
805 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
816 current: Some(mode_id.clone()),
817 available: available
818 .iter()
819 .map(|mode| AgentStreamNamedIdV1 {
820 id: mode.id.clone(),
821 name: mode.name.clone(),
822 description: mode.description.clone(),
823 })
824 .collect(),
825 },
826 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
830 let ProviderHostRequestDecision::Denied { by } = decision else {
831 return None;
832 };
833 let (tool_class, authority, reason_text) =
834 host_request_denied_block(method, *by, reason.as_deref());
835 AgentStreamChunkKindV1::Blocked {
836 correlation_id: None,
837 tool_class,
838 authority,
839 reason_kind: None,
840 reason: reason_text,
841 help: None,
842 }
843 }
844 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
848 let (authority, reason_kind) =
849 provider_tool_block_authority(non_execution_kind.as_deref())?;
850 let (reason, help) = split_provider_denial_text(output);
851 let (reason, _) =
852 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
853 let help = help.map(|help| {
854 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
855 });
856 AgentStreamChunkKindV1::Blocked {
857 correlation_id: Some(tool_correlation(event, source, id)),
858 tool_class: tool_class_from_denial_help(help.as_deref()),
859 authority,
860 reason_kind: Some(reason_kind.to_owned()),
861 reason,
862 help,
863 }
864 }
865 ProviderEvent::SessionEnded { stop_reason, .. } => {
869 let (authority, reason_kind, reason) =
870 stop_reason.as_ref().and_then(provider_session_end_block)?;
871 let (reason, _) =
872 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
873 AgentStreamChunkKindV1::Blocked {
874 correlation_id: None,
875 tool_class: "Session".to_owned(),
876 authority,
877 reason_kind: Some(reason_kind),
878 reason,
879 help: None,
880 }
881 }
882 ProviderEvent::SessionStarted { .. }
883 | ProviderEvent::SessionIdentityObserved { .. }
884 | ProviderEvent::TurnStarted { .. }
885 | ProviderEvent::WorkingObserved
886 | ProviderEvent::ToolStarted { .. }
887 | ProviderEvent::TurnCompleted { .. }
888 | ProviderEvent::ContextWindowUsage { .. }
889 | ProviderEvent::TurnInterrupted
890 | ProviderEvent::Error { .. }
891 | ProviderEvent::Ready
892 | ProviderEvent::InteractionResolved { .. }
893 | ProviderEvent::SubagentStarted { .. }
894 | ProviderEvent::SubagentStopped { .. }
895 | ProviderEvent::RateLimited { .. }
896 | ProviderEvent::UnrecognizedNotification { .. }
897 | ProviderEvent::UserMessage { .. }
898 | ProviderEvent::Plan { .. }
899 | ProviderEvent::AvailableCommandsUpdated { .. }
900 | ProviderEvent::SessionInfoUpdated { .. }
901 | ProviderEvent::UsageUpdated { .. } => return None,
902 };
903 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
904}
905
906async fn run_native_session_catalog_operation<T, E, F>(
907 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
908 operation: &'static str,
909 execute: F,
910) -> Result<T, NodeFailure>
911where
912 T: Send + 'static,
913 E: std::fmt::Display + Send + 'static,
914 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
915{
916 let task = tokio::task::spawn_blocking(move || {
917 let mut catalog = match catalog.try_lock() {
918 Ok(catalog) => catalog,
919 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
920 Err(std::sync::TryLockError::WouldBlock) => {
921 return Err(failure(
922 NodeFailureCode::BackendBusy,
923 "native session catalog operation is already running",
924 ));
925 }
926 };
927 execute(&mut catalog).map_err(|error| {
928 failure(
929 NodeFailureCode::BackendOperationFailed,
930 &format!("{operation} failed: {error}"),
931 )
932 })
933 });
934 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
935 .await
936 .map_err(|_| {
937 failure(
938 NodeFailureCode::BackendBusy,
939 &format!("{operation} exceeded its bounded deadline"),
940 )
941 })?
942 .map_err(|_| {
943 failure(
944 NodeFailureCode::BackendOperationFailed,
945 &format!("{operation} task failed"),
946 )
947 })?
948}
949
950fn windows_path_text(path: &OpaqueHostPath) -> &str {
951 path.as_utf8()
952 .expect("Windows node only creates UTF-8 opaque host paths")
953}
954
955fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
956 path.as_utf8()
957 .map(str::to_owned)
958 .ok_or_else(|| failure(
959 NodeFailureCode::InvalidRequest,
960 "this node requires UTF-8 host paths",
961 ))
962}
963
964fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
965 GitWorktreeSnapshot {
966 path: opaque_windows_path(worktree.path),
967 head: worktree.head,
968 branch: worktree.branch,
969 is_bare: worktree.is_bare,
970 is_main: worktree.is_main,
971 locked: worktree.locked,
972 lock_reason: worktree.lock_reason,
973 prunable: worktree.prunable,
974 prunable_reason: worktree.prunable_reason,
975 workspace_id: worktree.workspace_id.take(),
976 }
977}
978
979#[derive(Clone, Debug, Eq, PartialEq)]
980pub struct WorkspaceConfig {
981 workspace_id: WorkspaceId,
982 canonical_root: String,
983 worktree_service_mode: WorktreeServiceMode,
984 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
985}
986
987impl WorkspaceConfig {
988 pub fn new(
989 workspace_id: WorkspaceId,
990 root: impl AsRef<Path>,
991 ) -> Result<Self, NodeServerError> {
992 let root = root.as_ref();
993 if !root.is_absolute() {
994 return Err(NodeServerError::InvalidWorkspaceRoot {
995 workspace_id,
996 path: root.to_string_lossy().into_owned(),
997 message: "path is not absolute".to_owned(),
998 });
999 }
1000 let canonical = std::fs::canonicalize(root).map_err(|error| {
1001 NodeServerError::InvalidWorkspaceRoot {
1002 workspace_id: workspace_id.clone(),
1003 path: root.to_string_lossy().into_owned(),
1004 message: error.to_string(),
1005 }
1006 })?;
1007 if !canonical.is_dir() {
1008 return Err(NodeServerError::InvalidWorkspaceRoot {
1009 workspace_id,
1010 path: root.to_string_lossy().into_owned(),
1011 message: "path is not a directory".to_owned(),
1012 });
1013 }
1014 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1015 NodeServerError::InvalidWorkspaceRoot {
1016 workspace_id: workspace_id.clone(),
1017 path: path.to_string_lossy().into_owned(),
1018 message: "canonical path is not valid Unicode".to_owned(),
1019 }
1020 })?;
1021 let canonical_root = platform::normalize_canonical_root(canonical_root);
1022 validate_workspace_root(&workspace_id, &canonical_root)?;
1023 Ok(Self {
1024 workspace_id,
1025 canonical_root,
1026 worktree_service_mode: WorktreeServiceMode::Manual,
1027 managed_worktree_profiles: BTreeMap::new(),
1028 })
1029 }
1030
1031 pub fn workspace_id(&self) -> &WorkspaceId {
1032 &self.workspace_id
1033 }
1034
1035 pub fn canonical_root(&self) -> &str {
1036 &self.canonical_root
1037 }
1038
1039 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1040 self.worktree_service_mode = mode;
1041 self
1042 }
1043
1044 pub fn with_managed_worktree_profile(
1045 mut self,
1046 profile: ManagedWorktreeProfile,
1047 ) -> Result<Self, NodeServerError> {
1048 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1049 NodeServerError::InvalidManagedWorktreeProfile {
1050 workspace_id: self.workspace_id.clone(),
1051 profile_id: profile.profile_id().clone(),
1052 message,
1053 }
1054 })?;
1055 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1056 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1057 workspace_id: self.workspace_id,
1058 profile_id: profile.profile_id().clone(),
1059 });
1060 }
1061 if self.managed_worktree_profiles.len()
1062 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1063 {
1064 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1065 workspace_id: self.workspace_id,
1066 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1067 });
1068 }
1069 self.managed_worktree_profiles
1070 .insert(profile.profile_id().clone(), profile);
1071 Ok(self)
1072 }
1073}
1074
1075#[derive(Clone)]
1076pub struct NodeServerConfig {
1077 pub endpoint: String,
1078 api_listen: Option<std::net::SocketAddr>,
1079 bridge_listen: Option<std::net::SocketAddr>,
1082 bridge_token: Option<String>,
1085 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1088 call_home: Option<std::net::SocketAddr>,
1091 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1095 pub node_id: NodeId,
1096 pub workspaces: Vec<WorkspaceConfig>,
1097 access_token: String,
1098 pub runtime: NativeRuntimeConfig,
1099 session_record_retention: SessionRecordRetentionConfig,
1100 state_path: Option<PathBuf>,
1101 spawn_profiles: SpawnProfileRegistry,
1102 session_environment: Option<NodeSessionEnvironmentConfig>,
1103 history: Option<NativeHistoryConfig>,
1104 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1105 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1109 #[cfg(feature = "fixture")]
1110 fixture_raw_pty_runtime: bool,
1111}
1112
1113#[derive(Clone)]
1114struct NodeSessionEnvironmentConfig {
1115 root: PathBuf,
1116 resolver: Arc<dyn NodeSecretResolver>,
1117}
1118
1119impl NodeServerConfig {
1120 pub fn new(
1121 endpoint: impl Into<String>,
1122 access_token: impl Into<String>,
1123 node_id: NodeId,
1124 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1125 ) -> Result<Self, NodeServerError> {
1126 let endpoint = endpoint.into();
1127 let access_token = access_token.into();
1128 if !platform::validate_endpoint(&endpoint) {
1129 return Err(NodeServerError::InvalidEndpoint);
1130 }
1131 if access_token.is_empty() || access_token.len() > 4_096 {
1132 return Err(NodeServerError::InvalidAccessToken);
1133 }
1134 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1135 if workspaces.is_empty() {
1136 return Err(NodeServerError::NoWorkspaces);
1137 }
1138 let mut workspace_ids = BTreeMap::new();
1139 let mut workspace_roots = BTreeMap::new();
1140 for workspace in &workspaces {
1141 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1142 && workspace.managed_worktree_profiles.is_empty()
1143 {
1144 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1145 workspace.workspace_id.clone(),
1146 ));
1147 }
1148 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1149 && !workspace.managed_worktree_profiles.is_empty()
1150 {
1151 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1152 workspace.workspace_id.clone(),
1153 ));
1154 }
1155 if workspace_ids
1156 .insert(workspace.workspace_id.clone(), ())
1157 .is_some()
1158 {
1159 return Err(NodeServerError::DuplicateWorkspaceId(
1160 workspace.workspace_id.clone(),
1161 ));
1162 }
1163 let root_key = platform::root_identity(&workspace.canonical_root);
1164 if let Some(existing) = workspace_roots.insert(
1165 root_key,
1166 workspace.workspace_id.clone(),
1167 ) {
1168 return Err(NodeServerError::DuplicateWorkspaceRoot {
1169 first: existing,
1170 second: workspace.workspace_id.clone(),
1171 root: workspace.canonical_root.clone(),
1172 });
1173 }
1174 }
1175 Ok(Self {
1176 endpoint,
1177 api_listen: None,
1178 bridge_listen: None,
1179 bridge_token: None,
1180 bridge_underlay: None,
1181 call_home: None,
1182 call_home_identity: None,
1183 node_id,
1184 workspaces,
1185 access_token,
1186 runtime: NativeRuntimeConfig {
1189 worker_poll_interval_ms: 4,
1190 ..NativeRuntimeConfig::default()
1191 },
1192 session_record_retention: SessionRecordRetentionConfig::default(),
1193 state_path: None,
1194 spawn_profiles: SpawnProfileRegistry::default(),
1195 session_environment: None,
1196 history: None,
1197 harness_mcp_helper: None,
1198 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1199 #[cfg(feature = "fixture")]
1200 fixture_raw_pty_runtime: false,
1201 })
1202 }
1203
1204 pub fn with_api_listen(
1205 mut self,
1206 api_listen: std::net::SocketAddr,
1207 ) -> Result<Self, NodeServerError> {
1208 if !api_listen.ip().is_loopback() {
1209 return Err(NodeServerError::InvalidApiListen(api_listen));
1210 }
1211 self.api_listen = Some(api_listen);
1212 Ok(self)
1213 }
1214
1215 pub fn with_bridge_listen(
1219 mut self,
1220 bridge_listen: std::net::SocketAddr,
1221 ) -> Result<Self, NodeServerError> {
1222 if !bridge_listen.ip().is_loopback() {
1223 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1224 }
1225 self.bridge_listen = Some(bridge_listen);
1226 Ok(self)
1227 }
1228
1229 pub fn with_bridge_token(
1232 mut self,
1233 bridge_token: impl Into<String>,
1234 ) -> Result<Self, NodeServerError> {
1235 let bridge_token = bridge_token.into();
1236 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1237 return Err(NodeServerError::InvalidBridgeToken);
1238 }
1239 self.bridge_token = Some(bridge_token);
1240 Ok(self)
1241 }
1242
1243 pub fn with_bridge_underlay(
1248 mut self,
1249 bind: std::net::SocketAddr,
1250 transport_key: [u8; 32],
1251 underlay_token: impl Into<String>,
1252 ) -> Result<Self, NodeServerError> {
1253 if self.bridge_listen.is_none() {
1254 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1255 }
1256 if self.bridge_token.is_none() {
1257 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1258 }
1259 let underlay_token = underlay_token.into();
1260 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1261 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1262 }
1263 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1265 bind,
1266 transport_key,
1267 underlay_token,
1268 });
1269 Ok(self)
1270 }
1271
1272 pub fn with_call_home(
1293 mut self,
1294 relay: std::net::SocketAddr,
1295 ) -> Result<Self, NodeServerError> {
1296 if relay.port() == 0 || relay.ip().is_unspecified() {
1299 return Err(NodeServerError::InvalidCallHome(relay));
1300 }
1301 self.call_home = Some(relay);
1302 Ok(self)
1303 }
1304
1305 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1307 self.call_home_identity = Some(identity);
1308 self
1309 }
1310
1311 pub fn with_state_path(
1312 mut self,
1313 state_path: impl Into<PathBuf>,
1314 ) -> Result<Self, NodeServerError> {
1315 let state_path = state_path.into();
1316 if !state_path.is_absolute() || state_path.file_name().is_none() {
1317 return Err(NodeServerError::InvalidStatePath(
1318 state_path.to_string_lossy().into_owned(),
1319 ));
1320 }
1321 self.state_path = Some(state_path);
1322 Ok(self)
1323 }
1324
1325 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1326 self.spawn_profiles = spawn_profiles;
1327 self
1328 }
1329
1330 pub fn with_session_record_retention(
1336 mut self,
1337 retention: SessionRecordRetentionConfig,
1338 ) -> Self {
1339 self.session_record_retention = retention;
1340 self
1341 }
1342
1343 pub fn with_session_environment_materialization(
1344 mut self,
1345 root: impl Into<PathBuf>,
1346 resolver: Arc<dyn NodeSecretResolver>,
1347 ) -> Result<Self, NodeServerError> {
1348 let root = root.into();
1349 if !root.is_absolute() {
1350 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1351 }
1352 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1353 Ok(self)
1354 }
1355
1356 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1359 self.history = Some(history);
1360 self
1361 }
1362
1363 pub fn with_harness_mcp_helper(
1369 mut self,
1370 helper_program: impl Into<PathBuf>,
1371 ) -> Result<Self, NodeServerError> {
1372 self.harness_mcp_helper = Some(
1373 ReviewedHarnessMcpProgram::review(helper_program.into())
1374 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1375 );
1376 Ok(self)
1377 }
1378
1379 pub fn with_network_allowlist_catalog(
1384 mut self,
1385 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1386 ) -> Result<Self, NodeServerError> {
1387 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1388 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1389 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1390 });
1391 }
1392 self.network_allowlist_catalog = catalog;
1393 Ok(self)
1394 }
1395
1396 pub fn with_network_allowlist_catalog_file(
1400 self,
1401 path: impl AsRef<std::path::Path>,
1402 ) -> Result<Self, NodeServerError> {
1403 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1404 .map_err(NodeServerError::from)?;
1405 self.with_network_allowlist_catalog(catalog)
1406 }
1407}
1408
1409pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1410 platform::default_state_path(node_id.as_str())
1411 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1412}
1413
1414pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1415 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1416}
1417
1418fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1419 let parent = state_path.parent()?;
1420 let mut name = state_path.file_name()?.to_os_string();
1421 name.push(".delivery-store");
1422 Some(parent.join(name))
1423}
1424
1425fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1426 let parent = state_path.parent()?;
1427 let mut name = state_path.file_name()?.to_os_string();
1428 name.push(".context-pack-store");
1429 Some(parent.join(name))
1430}
1431
1432pub struct NodeServer {
1433 config: NodeServerConfig,
1434 runtime: NativeRuntime,
1435 shared: Arc<NodeShared>,
1436 events: EventSubscription,
1437 state_path_lock: Option<session_registry::StatePathLock>,
1438}
1439
1440#[cfg(feature = "fixture")]
1441#[derive(Clone, Default)]
1442pub struct SpawnManagedWorktreeV2FailureProbe {
1443 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1444}
1445
1446#[cfg(feature = "fixture")]
1447impl SpawnManagedWorktreeV2FailureProbe {
1448 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1449 *self
1450 .latest_code
1451 .lock()
1452 .unwrap_or_else(|poisoned| poisoned.into_inner())
1453 }
1454
1455 fn record(&self, code: Option<NodeFailureCode>) {
1456 *self
1457 .latest_code
1458 .lock()
1459 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1460 }
1461}
1462
1463#[cfg(all(feature = "fixture", windows))]
1464struct IsolatedCodexHomeFixtureEnvironment;
1465
1466#[cfg(all(feature = "fixture", windows))]
1467impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1468 for IsolatedCodexHomeFixtureEnvironment
1469{
1470 fn resolve_child_environment(
1471 &self,
1472 ) -> Result<
1473 Vec<gate4agent_catalog::EnvMutation>,
1474 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1475 > {
1476 Ok(vec![gate4agent_catalog::EnvMutation {
1477 key: OsString::from("USERPROFILE"),
1478 value: None,
1479 }])
1480 }
1481}
1482
1483impl NodeServer {
1484 #[cfg(unix)]
1488 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1489 self.shared.signer_proxy.set(proxy).is_ok()
1490 }
1491
1492 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1493 self.shared.m4a_accounts.as_ref()?;
1494 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1495 }
1496
1497 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1498 let catalog = active_registry()?;
1499 Self::new_with_registry(config, catalog)
1500 }
1501
1502 #[cfg(feature = "fixture")]
1503 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1504 let mut spec = gate4agent_testkit::interactive_agent_spec();
1505 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1506 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1507 Self::new_fixture_with_spec(config, spec)
1508 }
1509
1510 #[cfg(feature = "fixture")]
1511 pub fn new_clean_exit_fixture(
1512 config: NodeServerConfig,
1513 fixture_root: PathBuf,
1514 started_marker: PathBuf,
1515 release_signal: PathBuf,
1516 ) -> Result<Self, NodeServerError> {
1517 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1518 &fixture_root,
1519 &started_marker,
1520 &release_signal,
1521 )
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 spec.id = AgentId::new("claude")
1524 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1525 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1526 Self::new_fixture_with_spec(config, spec)
1527 }
1528
1529 #[cfg(all(feature = "fixture", windows))]
1539 pub fn new_durable_context_pack_clean_exit_fixture(
1540 config: NodeServerConfig,
1541 fixture_root: PathBuf,
1542 started_marker: PathBuf,
1543 release_signal: PathBuf,
1544 session_id: &str,
1545 ) -> Result<Self, NodeServerError> {
1546 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1547 &fixture_root,
1548 &started_marker,
1549 &release_signal,
1550 session_id,
1551 )
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let source_id = AgentId::new("claude")
1554 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1555 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1556 NodeServerError::Registry(
1557 "durable context pack clean-exit fixture source adapter is unavailable"
1558 .to_owned(),
1559 )
1560 })?;
1561 source_spec.capabilities.adapters.history =
1562 source_provider.capabilities.adapters.history.clone();
1563 source_spec.id = source_id;
1564 source_spec.display_name =
1565 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1566 source_spec.detection.command =
1572 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1573 source_spec.detection.aliases.clear();
1574
1575 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1576 target_spec.id = AgentId::new("codex")
1577 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1578 target_spec.display_name =
1579 "Controlled Codex durable context-pack target fixture".to_owned();
1580 target_spec.detection.command =
1581 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1582 target_spec.detection.aliases.clear();
1583
1584 let catalog = AgentRegistry::new([source_spec, target_spec])
1585 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586 let mut server = Self::new_with_registry(config, catalog)?;
1587 Arc::get_mut(&mut server.shared)
1588 .expect("fixture Node shared state must remain uniquely owned before run")
1589 .fixture_semantic_hook_policy = true;
1590 Ok(server)
1591 }
1592
1593 #[cfg(feature = "fixture")]
1594 pub fn new_monitoring_hook_fixture(
1595 config: NodeServerConfig,
1596 ) -> Result<Self, NodeServerError> {
1597 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1598 spec.id = AgentId::new("claude")
1599 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600 let mut server = Self::new_fixture_with_spec(config, spec)?;
1601 Arc::get_mut(&mut server.shared)
1602 .expect("fixture Node shared state must remain uniquely owned before run")
1603 .fixture_semantic_hook_policy = true;
1604 Ok(server)
1605 }
1606
1607 #[cfg(all(feature = "fixture", windows))]
1608 pub fn new_provider_bundle_argv_fixture(
1609 config: NodeServerConfig,
1610 agent_id: AgentId,
1611 proof_path: PathBuf,
1612 ) -> Result<Self, NodeServerError> {
1613 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1614 Self::new_fixture_with_spec(config, spec)
1615 }
1616
1617 #[cfg(all(feature = "fixture", windows))]
1618 pub fn new_provider_bundle_argv_hold_fixture(
1619 config: NodeServerConfig,
1620 agent_id: AgentId,
1621 proof_path: PathBuf,
1622 release_signal: PathBuf,
1623 ) -> Result<Self, NodeServerError> {
1624 let spec = Self::provider_bundle_argv_fixture_spec(
1625 agent_id,
1626 proof_path,
1627 Some(release_signal),
1628 )?;
1629 Self::new_fixture_with_spec(config, spec)
1630 }
1631
1632 #[cfg(all(feature = "fixture", windows))]
1633 fn provider_bundle_argv_fixture_spec(
1634 agent_id: AgentId,
1635 proof_path: PathBuf,
1636 release_signal: Option<PathBuf>,
1637 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1638 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1639 return Err(NodeServerError::Registry(
1640 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1641 ));
1642 }
1643 let mut spec = gate4agent_testkit::interactive_agent_spec();
1644 spec.id = agent_id.clone();
1645 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1646 let provider = builtin_registry()
1647 .get(&agent_id)
1648 .ok_or_else(|| {
1649 NodeServerError::Registry(
1650 "provider bundle argv fixture adapter is unavailable".to_owned(),
1651 )
1652 })?;
1653 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1654 if !proof_path.is_absolute()
1655 || !proof_path
1656 .parent()
1657 .is_some_and(|parent| parent.is_dir())
1658 {
1659 return Err(NodeServerError::Registry(
1660 "provider bundle argv proof path requires an existing absolute parent"
1661 .to_owned(),
1662 ));
1663 }
1664 if let Some(release_signal) = release_signal.as_ref() {
1665 if !release_signal.is_absolute()
1666 || !release_signal
1667 .parent()
1668 .is_some_and(|parent| parent.is_dir())
1669 {
1670 return Err(NodeServerError::Registry(
1671 "provider bundle argv release path requires an existing absolute parent"
1672 .to_owned(),
1673 ));
1674 }
1675 if release_signal.exists() {
1676 return Err(NodeServerError::Registry(
1677 "provider bundle argv release path must not exist".to_owned(),
1678 ));
1679 }
1680 let proof_parent = std::fs::canonicalize(
1681 proof_path
1682 .parent()
1683 .expect("validated provider bundle proof parent"),
1684 )
1685 .map_err(|_| {
1686 NodeServerError::Registry(
1687 "provider bundle argv proof parent is unavailable".to_owned(),
1688 )
1689 })?;
1690 let release_parent = std::fs::canonicalize(
1691 release_signal
1692 .parent()
1693 .expect("validated provider bundle release parent"),
1694 )
1695 .map_err(|_| {
1696 NodeServerError::Registry(
1697 "provider bundle argv release parent is unavailable".to_owned(),
1698 )
1699 })?;
1700 if proof_parent != release_parent {
1701 return Err(NodeServerError::Registry(
1702 "provider bundle argv release path must share the proof parent".to_owned(),
1703 ));
1704 }
1705 }
1706 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1707 NodeServerError::Registry(
1708 "provider bundle argv proof path is not valid Unicode".to_owned(),
1709 )
1710 })?;
1711 let release_signal = release_signal.map(|release_signal| {
1712 release_signal.into_os_string().into_string().map_err(|_| {
1713 NodeServerError::Registry(
1714 "provider bundle argv release path is not valid Unicode".to_owned(),
1715 )
1716 })
1717 }).transpose()?;
1718 let script = spec
1719 .launch
1720 .fixed_args
1721 .pop()
1722 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1723 let bundle_validation = if agent_id.as_str() == "codex" {
1724 "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1725 } else if agent_id.as_str() == "claude" {
1726 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1727 } else {
1728 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1729 };
1730 if let Some(release_signal) = release_signal {
1731 spec.launch.fixed_args.push(format!(
1732 "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1733 ));
1734 spec.launch.fixed_args.push(proof_path);
1735 spec.launch.fixed_args.push(release_signal);
1736 } else {
1737 spec.launch.fixed_args.push(format!(
1738 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1739 ));
1740 spec.launch.fixed_args.push(proof_path);
1741 }
1742 Ok(spec)
1743 }
1744
1745 #[cfg(all(feature = "fixture", windows))]
1746 pub fn new_context_pack_fixture(
1747 config: NodeServerConfig,
1748 proof_path: PathBuf,
1749 ) -> Result<Self, NodeServerError> {
1750 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1751 Self::new_with_registry(config, catalog)
1752 }
1753
1754 #[cfg(all(feature = "fixture", windows))]
1755 pub fn new_monitoring_context_pack_fixture(
1756 config: NodeServerConfig,
1757 proof_path: PathBuf,
1758 ) -> Result<Self, NodeServerError> {
1759 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1760 let mut server = Self::new_with_registry(config, catalog)?;
1761 Arc::get_mut(&mut server.shared)
1762 .expect("fixture Node shared state must remain uniquely owned before run")
1763 .fixture_semantic_hook_policy = true;
1764 Ok(server)
1765 }
1766
1767 #[cfg(all(feature = "fixture", windows))]
1768 pub fn install_isolated_codex_home_environment_fixture(
1769 &mut self,
1770 profile_id: SpawnEnvironmentProfileId,
1771 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1772 ) -> Result<(), NodeServerError> {
1773 let provider = AgentId::new("codex")
1774 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1775 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1776 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1777 "{}-pty",
1778 profile_id.as_str(),
1779 ))
1780 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1781 provider.clone(),
1782 TransportKind::Pty,
1783 vec![OsString::from("USERPROFILE")],
1784 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1785 )
1786 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787 let materialization = NodeSessionMaterializationProfile::new(
1788 Vec::new(),
1789 vec![crate::session_environment::NodeSessionPathBinding::new(
1790 "CODEX_HOME",
1791 crate::session_environment::NodeSessionPathClass::ProviderHome,
1792 )
1793 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1794 Vec::new(),
1795 )
1796 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1797 self.install_environment_profile(
1798 NodeEnvironmentProfile::new_with_materialization(
1799 profile_id,
1800 profile_revision,
1801 provider,
1802 [native_profile],
1803 Some(materialization),
1804 )
1805 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1806 )
1807 }
1808
1809 #[cfg(all(feature = "fixture", windows))]
1810 pub fn new_context_only_proof_fixture(
1811 config: NodeServerConfig,
1812 target_provider: AgentId,
1813 proof_path: PathBuf,
1814 ) -> Result<Self, NodeServerError> {
1815 if target_provider.as_str() != "kimi" {
1816 return Err(NodeServerError::Registry(
1817 "context-only proof fixture requires Kimi".to_owned(),
1818 ));
1819 }
1820 let catalog = Self::context_pack_fixture_catalog(
1821 proof_path,
1822 Some(&target_provider),
1823 false,
1824 )?;
1825 Self::new_with_registry(config, catalog)
1826 }
1827
1828 #[cfg(all(feature = "fixture", windows))]
1829 fn context_pack_fixture_catalog(
1830 proof_path: PathBuf,
1831 context_only_target: Option<&AgentId>,
1832 monitoring_claude_source: bool,
1833 ) -> Result<AgentRegistry, NodeServerError> {
1834 if !proof_path.is_absolute()
1835 || !proof_path
1836 .parent()
1837 .is_some_and(|parent| parent.is_dir())
1838 {
1839 return Err(NodeServerError::Registry(
1840 "context pack proof path requires an existing absolute parent".to_owned(),
1841 ));
1842 }
1843 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1844 NodeServerError::Registry(
1845 "context pack proof path is not valid Unicode".to_owned(),
1846 )
1847 })?;
1848 let providers = ["claude", "codex", "grok", "kimi"];
1849 let builtins = builtin_registry();
1850 let mut specs = Vec::with_capacity(providers.len());
1851 for provider_id in providers {
1852 let agent_id = AgentId::new(provider_id)
1853 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1854 let provider = builtins.get(&agent_id).ok_or_else(|| {
1855 NodeServerError::Registry(
1856 "context pack fixture provider adapter is unavailable".to_owned(),
1857 )
1858 })?;
1859 let monitored_provider =
1860 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1861 let mut spec = if monitored_provider {
1862 gate4agent_testkit::monitoring_hook_agent_spec()
1863 } else {
1864 gate4agent_testkit::interactive_agent_spec()
1865 };
1866 spec.id = agent_id.clone();
1867 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1868 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1869 spec.detection.aliases.clear();
1870 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1871 if provider_id == "codex" {
1872 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1873 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1874 })?;
1875 let validation = r#"
1876$bundleArgs = @($bundleArgs)
1877if ($bundleArgs.Count -ne 0) { exit 91 }
1878$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1879$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1880$cwd = (Get-Location).ProviderPath
1881if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1882$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1883if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1884$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1885if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1886$sha = [Security.Cryptography.SHA256]::Create()
1887try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1888if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1889if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1890$contextPath = Join-Path $contextRoot 'context-pack.json'
1891if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1892$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1893if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1894$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1896$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1897$separator = [IO.Path]::DirectorySeparatorChar
1898if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1899if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1900$rawContext = [IO.File]::ReadAllText($contextPath)
1901if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1902try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1903$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1904$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1905foreach ($property in @($document.PSObject.Properties.Name)) {
1906 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1907}
1908foreach ($property in $requiredTopLevel) {
1909 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1910}
1911if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1912if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1913$messages = @($document.retained_messages)
1914if ($messages.Count -eq 0) { exit 107 }
1915foreach ($message in $messages) {
1916 $messageProperties = @($message.PSObject.Properties.Name)
1917 foreach ($property in $messageProperties) {
1918 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1919 }
1920 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1921}
1922$sha = [Security.Cryptography.SHA256]::Create()
1923try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1924[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1925$rawProof = [IO.File]::ReadAllText($proofPath)
1926if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1927[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1928"#;
1929 spec.launch.fixed_args.push(format!(
1930 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1931 ));
1932 spec.launch.fixed_args.push(proof_path.clone());
1933 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1934 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1935 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1936 })?;
1937 let validation = r#"
1938$bundleArgs = @($bundleArgs)
1939if ($bundleArgs.Count -ne 0) { exit 91 }
1940$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1941$cwd = (Get-Location).ProviderPath
1942if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1943$contextPath = Join-Path $contextRoot 'context-pack.json'
1944if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1945$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1946if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1947$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1948$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1949$separator = [IO.Path]::DirectorySeparatorChar
1950if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1951try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1952if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1953if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1954$messages = @($document.retained_messages)
1955if ($messages.Count -eq 0) { exit 99 }
1956$hasUser = $false
1957$hasAssistant = $false
1958foreach ($message in $messages) {
1959 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1960 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1961 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1962 else { exit 101 }
1963}
1964if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1965$sha = [Security.Cryptography.SHA256]::Create()
1966try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1967[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1968[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1969"#;
1970 spec.launch.fixed_args.push(format!(
1971 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1972 ));
1973 spec.launch.fixed_args.push(proof_path.clone());
1974 }
1975 specs.push(spec);
1976 }
1977 let catalog = AgentRegistry::new(specs)
1978 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1979 Ok(catalog)
1980 }
1981
1982 #[cfg(feature = "fixture")]
1983 pub fn new_exact_launcher_fixture(
1984 config: NodeServerConfig,
1985 agent_id: AgentId,
1986 launcher: String,
1987 ) -> Result<Self, NodeServerError> {
1988 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1989 return Err(NodeServerError::Registry(
1990 "exact launcher fixture requires a supported provider".to_owned(),
1991 ));
1992 }
1993 if launcher.contains('\0') {
1994 return Err(NodeServerError::Registry(
1995 "exact launcher fixture path contains NUL".to_owned(),
1996 ));
1997 }
1998 let launcher_path = Path::new(&launcher);
1999 if !launcher_path.is_absolute() {
2000 return Err(NodeServerError::Registry(
2001 "exact launcher fixture path must be absolute".to_owned(),
2002 ));
2003 }
2004 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2005 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2006 })?;
2007 if !metadata.is_file() {
2008 return Err(NodeServerError::Registry(
2009 "exact launcher fixture path must be a regular file".to_owned(),
2010 ));
2011 }
2012 let mut spec = builtin_registry()
2013 .get(&agent_id)
2014 .ok_or_else(|| {
2015 NodeServerError::Registry(
2016 "exact launcher fixture provider is unavailable".to_owned(),
2017 )
2018 })?
2019 .clone();
2020 spec.launch.program = launcher;
2021 Self::new_fixture_with_spec(config, spec)
2022 }
2023
2024 #[cfg(feature = "fixture")]
2033 fn h3b_provider_spec_with_program_and_args(
2034 agent_id: AgentId,
2035 provider_program: PathBuf,
2036 fixed_args: Vec<OsString>,
2037 ) -> Result<AgentSpec, NodeServerError> {
2038 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2039 return Err(NodeServerError::Registry(
2040 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2041 ));
2042 }
2043 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2044 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2045 })?;
2046 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2047 return Err(NodeServerError::Registry(
2048 "H3B fixture provider must be an exact regular file".to_owned(),
2049 ));
2050 }
2051 let program = provider_program.into_os_string().into_string().map_err(|_| {
2052 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2053 })?;
2054 let fixed_args = fixed_args.into_iter().map(|argument| {
2055 argument.into_string().map_err(|_| NodeServerError::Registry(
2056 "H3B fixture argument is not Unicode".to_owned(),
2057 ))
2058 }).collect::<Result<Vec<_>, _>>()?;
2059 let argument_bytes = fixed_args.iter()
2060 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2061 if program.contains('\0')
2062 || fixed_args.iter().any(|argument| argument.contains('\0'))
2063 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2064 {
2065 return Err(NodeServerError::Registry(
2066 "H3B fixture provider or arguments are invalid".to_owned(),
2067 ));
2068 }
2069 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2070 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2071 })?.clone();
2072 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2073 .ok_or_else(|| NodeServerError::Registry(
2074 "H3B fixture provider basename is unavailable".to_owned(),
2075 ))?.to_owned();
2076 spec.detection.command = process_name.clone();
2077 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2078 name: process_name,
2079 }];
2080 spec.launch.program = program;
2081 spec.launch.fixed_args = fixed_args;
2082 Ok(spec)
2083 }
2084
2085 #[cfg(feature = "fixture")]
2086 pub fn new_harness_mcp_proxy_fixture(
2087 config: NodeServerConfig,
2088 provider_program: PathBuf,
2089 fixed_args: Vec<OsString>,
2090 harness_mcp_program: PathBuf,
2091 ) -> Result<Self, NodeServerError> {
2092 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2093 let agent_id = AgentId::new("codex")
2094 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2095 let mut spec =
2096 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2097 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2098 config.fixture_raw_pty_runtime = true;
2099 Self::new_fixture_with_spec(config, spec)
2100 }
2101
2102 #[cfg(feature = "fixture")]
2126 pub fn new_harness_mcp_acp_launcher_fixture(
2127 config: NodeServerConfig,
2128 helper_program: PathBuf,
2129 provider: AgentId,
2130 program: PathBuf,
2131 args: Vec<OsString>,
2132 ) -> Result<Self, NodeServerError> {
2133 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2134 return Err(NodeServerError::Registry(
2135 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2136 ));
2137 }
2138 let config = config.with_harness_mcp_helper(helper_program)?;
2139 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2140 let launch = spec.launch.clone();
2141 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2142 NodeServerError::Registry(
2143 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2144 .to_owned(),
2145 )
2146 })?;
2147 acp.launch_override = Some(launch);
2155 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2156 Self::new_fixture_with_spec(config, spec)
2157 }
2158
2159 #[cfg(feature = "fixture")]
2160 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2161 let mut spec = gate4agent_testkit::interactive_agent_spec();
2162 let claude_id = AgentId::new("claude")
2163 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2164 let claude = builtin_registry()
2165 .get(&claude_id)
2166 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2167 spec.id = claude_id;
2168 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2169 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2170 name: "claude".to_owned(),
2171 }];
2172 spec.capabilities.transports.pty_adapter = claude
2173 .capabilities
2174 .transports
2175 .pty_adapter
2176 .clone();
2177 spec.capabilities.adapters.resume = claude
2178 .capabilities
2179 .adapters
2180 .resume
2181 .clone();
2182 let script = spec
2183 .launch
2184 .fixed_args
2185 .pop()
2186 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2187 spec.launch.fixed_args.push(format!(
2188 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2189 ));
2190 Self::new_fixture_with_spec(config, spec)
2191 }
2192
2193 #[cfg(feature = "fixture")]
2194 pub fn new_cmd_cwd_fixture(
2195 config: NodeServerConfig,
2196 command: String,
2197 ) -> Result<Self, NodeServerError> {
2198 let mut spec = gate4agent_testkit::interactive_agent_spec();
2199 spec.id = AgentId::new("claude")
2200 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2201 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2202 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2203 spec.launch.program = command;
2204 spec.launch.fixed_args.clear();
2205 Self::new_fixture_with_spec(config, spec)
2206 }
2207
2208 #[cfg(feature = "fixture")]
2209 fn new_fixture_with_spec(
2210 config: NodeServerConfig,
2211 spec: gate4agent_types::AgentSpec,
2212 ) -> Result<Self, NodeServerError> {
2213 let catalog = AgentRegistry::new([spec])
2214 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215 Self::new_with_registry(config, catalog)
2216 }
2217
2218 fn new_with_registry(
2219 config: NodeServerConfig,
2220 catalog: AgentRegistry,
2221 ) -> Result<Self, NodeServerError> {
2222 for profile in config.spawn_profiles.iter() {
2223 if catalog.get(&profile.provider).is_none() {
2224 return Err(NodeServerError::Registry(
2225 "spawn profile references an unavailable provider".to_owned(),
2226 ));
2227 }
2228 }
2229 let input_settle_timeout_ms = catalog
2230 .iter()
2231 .map(|spec| spec.readiness.timeout_ms)
2232 .max()
2233 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2234 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2235 .max(MUTATION_SETTLE_TIMEOUT_MS);
2236 let (provider_contracts, provider_adapter_contracts) =
2237 provider_contract_manifest(&catalog)?;
2238 let compatibility_support = node_compatibility_support_for_manifest(
2239 &provider_contracts,
2240 &provider_adapter_contracts,
2241 )?;
2242 validate_node_negotiated_handshake_capacity(&compatibility_support)
2243 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2244 let enabled_providers = provider_contracts
2245 .iter()
2246 .map(|contract| contract.provider.clone())
2247 .collect();
2248 #[cfg(feature = "fixture")]
2249 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2250 #[cfg(not(feature = "fixture"))]
2251 let fixture_raw_pty_runtime = false;
2252 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2253 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2254 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2255 })).expect("fixture provider catalog remains bounded");
2256 (None, statuses)
2257 } else {
2258 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2259 let statuses = monitor.collect();
2260 (Some(monitor), statuses)
2261 };
2262 let state_path_lock =
2263 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2264 .map_err(|error| {
2265 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2266 })?;
2267 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2268 Some(state_path) => {
2269 let delivery_root = delivery_store_root_for_state_path(state_path)
2270 .ok_or_else(|| {
2271 NodeServerError::DeliveryStore(
2272 "delivery store root could not be derived from state path".to_owned(),
2273 )
2274 })?;
2275 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2276 let detail = match &error {
2277 DeliveryStoreError::Storage(io_error) => {
2278 format!("{error}: {io_error}")
2279 }
2280 _ => error.to_string(),
2281 };
2282 NodeServerError::DeliveryStore(detail)
2283 })?;
2284 (Some(store), bundles)
2285 }
2286 None => (None, Vec::new()),
2287 };
2288 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2289 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2290 })?;
2291 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2292 Some(state_path) => {
2293 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2294 .ok_or(NodeServerError::ContextPackStore)?;
2295 let (store, packs) = ContextPackStore::open(context_pack_root)
2296 .map_err(|_| NodeServerError::ContextPackStore)?;
2297 (Some(store), packs)
2298 }
2299 None => (None, Vec::new()),
2300 };
2301 let mut durable_context_catalog = ContextPackCatalog::default();
2302 for pack in durable_context_packs {
2303 durable_context_catalog
2304 .insert(pack)
2305 .map_err(|_| NodeServerError::ContextPackStore)?;
2306 }
2307 let session_environment_materializer = config
2308 .session_environment
2309 .as_ref()
2310 .map(|environment| {
2311 SessionEnvironmentMaterializer::new(
2312 environment.root.clone(),
2313 Arc::clone(&environment.resolver),
2314 )
2315 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2316 })
2317 .transpose()?;
2318 #[cfg(unix)]
2321 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2322 let native_session_catalog = config.history.clone().map(|history| {
2323 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2324 });
2325 let (handle, runtime) = match config.history.clone() {
2326 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2327 None => NativeRuntime::new(catalog, config.runtime),
2328 };
2329 let native_launch_profile_control = runtime.native_launch_profile_control();
2330 let harness_mcp_registry = config
2331 .harness_mcp_helper
2332 .clone()
2333 .map(HarnessMcpProxyRegistry::new);
2334 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2335 let incarnation_id = random_incarnation_id()
2336 .map_err(NodeServerError::IncarnationIdentity)?;
2337 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2338 .map_err(durable_state_load_error)?;
2339 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2340 let managed_worktree_tombstones =
2341 std::mem::take(&mut loaded.managed_worktree_tombstones);
2342 let materializations = std::mem::take(&mut loaded.materializations);
2343 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2344 let (workspaces, records, persistence_warning) =
2345 merge_durable_state(&config.workspaces, loaded)?;
2346 let mut shared = NodeShared::new_with_incarnation(
2347 handle,
2348 config.access_token.clone(),
2349 config.node_id.clone(),
2350 incarnation_id,
2351 workspaces,
2352 enabled_providers,
2353 provider_runtime_statuses,
2354 provider_runtime_monitor,
2355 provider_contracts,
2356 provider_adapter_contracts,
2357 config.spawn_profiles.clone(),
2358 Some(native_launch_profile_control),
2359 native_session_catalog,
2360 session_environment_materializer,
2361 config.state_path.clone(),
2362 records,
2363 managed_worktrees,
2364 managed_worktree_tombstones,
2365 materializations,
2366 persistence_warning,
2367 input_settle_timeout_ms,
2368 );
2369 shared.harness_mcp_registry = harness_mcp_registry.clone();
2370 shared.session_record_retention = config.session_record_retention;
2371 shared.network_allowlist_catalog =
2372 RwLock::new(config.network_allowlist_catalog.clone());
2373 shared.bundle_catalog = RwLock::new(delivered_catalog);
2374 shared.delivery_store = Mutex::new(delivery_store);
2375 shared.context_catalog = RwLock::new(durable_context_catalog);
2376 shared.context_pack_store = Mutex::new(context_pack_store);
2377 shared.managed_spawn_replays = Mutex::new(
2378 managed_spawn_replays
2379 .into_iter()
2380 .map(|record| (record.idempotency_key.clone(), record))
2381 .collect(),
2382 );
2383 let shared = Arc::new(shared);
2384 if let Some(registry) = harness_mcp_registry {
2385 let weak = Arc::downgrade(&shared);
2386 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2387 Some(shared) => shared.publish_transient(event),
2388 None => false,
2389 }));
2390 }
2391 if shared.persistence_error().is_none() {
2392 shared.persist_state().map_err(|error| {
2393 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2394 })?;
2395 }
2396 Ok(Self {
2397 config,
2398 runtime,
2399 shared,
2400 events,
2401 state_path_lock,
2402 })
2403 }
2404
2405 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2406 NodeShutdownHandle {
2407 shared: Arc::clone(&self.shared),
2408 }
2409 }
2410
2411 #[cfg(feature = "fixture")]
2412 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2413 &self,
2414 ) -> SpawnManagedWorktreeV2FailureProbe {
2415 self.shared
2416 .fixture_spawn_managed_worktree_v2_failure_probe
2417 .clone()
2418 }
2419
2420 pub fn install_environment_profile(
2425 &mut self,
2426 profile: NodeEnvironmentProfile,
2427 ) -> Result<(), NodeServerError> {
2428 if !self.shared.enabled_providers.contains(profile.provider()) {
2429 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2430 profile.provider().clone(),
2431 ));
2432 }
2433 let (binding, native_profiles, materialization) = profile.into_parts();
2434 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2435 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2436 }
2437 let mut profiles = self
2438 .shared
2439 .environment_profiles
2440 .write()
2441 .unwrap_or_else(|poisoned| poisoned.into_inner());
2442 if profiles.contains_key(&binding.id) {
2443 return Err(NodeServerError::DuplicateEnvironmentProfile(
2444 binding.id.clone(),
2445 ));
2446 }
2447 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2448 return Err(NodeServerError::EnvironmentProfileCapacity {
2449 max: MAX_NODE_ENVIRONMENT_PROFILES,
2450 });
2451 }
2452 for native_id in binding.native_profile_ids() {
2453 if profiles.values().any(|current| {
2454 current.native_profile_ids().any(|current_id| current_id == native_id)
2455 }) {
2456 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2457 native_id.to_string(),
2458 ));
2459 }
2460 }
2461
2462 let mut installed = Vec::with_capacity(native_profiles.len());
2463 for native_profile in native_profiles {
2464 let native_id = native_profile.id().clone();
2465 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2466 for installed_id in installed.into_iter().rev() {
2467 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2468 }
2469 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2470 }
2471 installed.push(native_id);
2472 }
2473 if let Some(materialization) = materialization {
2474 self.shared
2475 .environment_materialization_profiles
2476 .write()
2477 .unwrap_or_else(|poisoned| poisoned.into_inner())
2478 .insert(binding.id.clone(), materialization);
2479 }
2480 profiles.insert(binding.id.clone(), binding);
2481 Ok(())
2482 }
2483
2484 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2486 if self.shared.session_environment_materializer.is_none() {
2487 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2488 }
2489 let mut catalog = self
2490 .shared
2491 .bundle_catalog
2492 .write()
2493 .unwrap_or_else(|poisoned| poisoned.into_inner());
2494 catalog
2495 .insert_idempotent(bundle)
2496 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2497 Ok(())
2498 }
2499
2500 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2501 let shutdown = self.shutdown_handle();
2502 let run = self.run();
2503 tokio::pin!(run);
2504 tokio::select! {
2505 result = &mut run => result,
2506 signal = wait_for_ctrl_signal() => {
2507 signal?;
2508 let shutdown_result = shutdown.request_shutdown().await;
2509 let run_result = run.await;
2510 shutdown_result.and(run_result)
2511 }
2512 }
2513 }
2514
2515 pub async fn run(self) -> Result<(), NodeServerError> {
2516 let NodeServer {
2517 config,
2518 mut runtime,
2519 shared,
2520 events,
2521 state_path_lock: _state_path_lock,
2522 } = self;
2523 shared.reconcile_materializations();
2524 shared.reconcile_managed_worktrees().await;
2525 shared.retire_unavailable_session_records().await;
2526 shared.reconcile_context_pack_exports().await;
2527 {
2528 let restore = Arc::clone(&shared);
2529 tokio::spawn(async move {
2530 sleep(Duration::from_secs(3)).await;
2531 restore.restore_journalled_sessions().await;
2532 loop {
2533 restore.refresh_session_journal();
2534 if restore.shutdown.load(Ordering::Acquire) { break; }
2535 sleep(Duration::from_secs(10)).await;
2536 }
2537 });
2538 }
2539 let endpoint = config.endpoint.clone();
2543 let call_home = config.call_home;
2544 let call_home_identity = config.call_home_identity.clone();
2545 let api_listen = config.api_listen;
2546 let bridge_listen = config.bridge_listen;
2547 let bridge_underlay = config.bridge_underlay;
2548 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2549 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2550 let accept_shared = Arc::clone(&shared);
2551 let api_shared = Arc::clone(&shared);
2552 let bridge_shared = Arc::clone(&shared);
2553 let shutdown_shared = Arc::clone(&shared);
2554 let shutdown_timeout = Duration::from_millis(
2555 config.runtime.provider_shutdown_timeout_ms.max(1),
2556 );
2557 let result = {
2558 let runtime_loop = drive_runtime_until_shutdown(
2559 &mut runtime,
2560 events,
2561 Arc::clone(&shared),
2562 shutdown_timeout,
2563 );
2564 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2565 let api_loop = http_api::run(api_listen, api_shared);
2566 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2567 tokio::pin!(runtime_loop);
2568 tokio::pin!(accept_loop);
2569 tokio::pin!(api_loop);
2570 tokio::pin!(bridge_loop);
2571 tokio::select! {
2572 runtime_result = &mut runtime_loop => {
2573 let shutdown_result = shutdown_shared
2574 .begin_shutdown()
2575 .await
2576 .map_err(NodeServerError::ShutdownDispatch);
2577 let (accept_result, api_result, bridge_result) =
2578 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2579 match runtime_result {
2580 Err(error) => Err(error),
2581 Ok(()) => shutdown_result
2582 .and(accept_result)
2583 .and(api_result.map_err(NodeServerError::HttpApi))
2584 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2585 }
2586 }
2587 accept_result = &mut accept_loop => {
2588 let shutdown_result = shutdown_shared
2589 .begin_shutdown()
2590 .await
2591 .map_err(NodeServerError::ShutdownDispatch);
2592 let (runtime_result, api_result, bridge_result) =
2593 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2594 match accept_result {
2595 Err(error) => Err(error),
2596 Ok(()) => shutdown_result
2597 .and(runtime_result)
2598 .and(api_result.map_err(NodeServerError::HttpApi))
2599 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600 }
2601 }
2602 api_result = &mut api_loop => {
2603 let shutdown_result = shutdown_shared
2604 .begin_shutdown()
2605 .await
2606 .map_err(NodeServerError::ShutdownDispatch);
2607 let (runtime_result, accept_result, bridge_result) =
2608 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2609 match api_result {
2610 Err(error) => Err(NodeServerError::HttpApi(error)),
2611 Ok(()) => shutdown_result
2612 .and(runtime_result)
2613 .and(accept_result)
2614 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615 }
2616 }
2617 bridge_result = &mut bridge_loop => {
2618 let shutdown_result = shutdown_shared
2619 .begin_shutdown()
2620 .await
2621 .map_err(NodeServerError::ShutdownDispatch);
2622 let (runtime_result, accept_result, api_result) =
2623 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2624 match bridge_result {
2625 Err(error) => Err(NodeServerError::BridgeApi(error)),
2626 Ok(()) => shutdown_result
2627 .and(runtime_result)
2628 .and(accept_result)
2629 .and(api_result.map_err(NodeServerError::HttpApi)),
2630 }
2631 }
2632 }
2633 };
2634 if let Ok(mut guard) = shared.delivery_store.lock() {
2641 *guard = None;
2642 }
2643 if let Ok(mut guard) = shared.context_pack_store.lock() {
2644 *guard = None;
2645 }
2646 result
2647 }
2648}
2649
2650async fn drive_runtime_until_shutdown(
2651 runtime: &mut NativeRuntime,
2652 events: EventSubscription,
2653 shared: Arc<NodeShared>,
2654 shutdown_timeout: Duration,
2655) -> Result<(), NodeServerError> {
2656 let _ = shared
2660 .shell_efficiency_profile
2661 .set(runtime.shell_efficiency_profile());
2662 let mut shutdown_started = None;
2663 let mut last_retention_sweep = Instant::now();
2664 let mut last_provisional_reap = Instant::now();
2665 loop {
2666 let runtime_tick_start = Instant::now();
2673 let tick_result = runtime.tick().await;
2674 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2675
2676 let event_drain_start = Instant::now();
2677 for outcome in &tick_result.command_outcomes {
2685 if let Err(error) = &outcome.result {
2686 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2687 tracing::warn!(
2688 command_id = outcome.command_id.0,
2689 cause = %error,
2690 "control command rejected"
2691 );
2692 shared.record_rejected_command(
2693 outcome.command_id,
2694 error.to_string(),
2695 error.is_unsupported_transport(),
2696 );
2697 }
2698 }
2699 let mut events_drained: u64 = 0;
2700 while let Ok(event) = events.try_recv() {
2701 events_drained += 1;
2702 let clean_exit = matches!(
2703 event.event,
2704 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2705 );
2706 let instance_id = event.instance_id;
2707 let generation = event.generation;
2708 if clean_exit {
2720 if let Some((record_id, session)) =
2721 shared.managed_record_export_target_for_instance(instance_id, generation)
2722 {
2723 let export_shared = Arc::clone(&shared);
2724 tokio::spawn(async move {
2725 export_shared
2726 .reconcile_context_pack_export_for_record(&record_id, session)
2727 .await;
2728 });
2729 }
2730 }
2731 shared.publish_control(event);
2732 }
2733 let event_drain_elapsed = event_drain_start.elapsed();
2734
2735 let publish_terminal_frames_start = Instant::now();
2736 shared.publish_terminal_frames();
2737 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2738
2739 shared
2745 .native_session_gauge
2746 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2747 *shared
2748 .runtime_tick_profile
2749 .lock()
2750 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2751
2752 let remainder_start = Instant::now();
2753 if shared.shutdown.load(Ordering::Acquire) {
2754 let started = *shutdown_started.get_or_insert_with(Instant::now);
2755 let snapshot = shared.handle.snapshot();
2756 let sessions_settled = snapshot.sessions.iter().all(|session| {
2757 !matches!(
2758 session.status,
2759 gate4agent_types::SessionStatus::Starting
2760 | gate4agent_types::SessionStatus::Running
2761 | gate4agent_types::SessionStatus::Stopping
2762 )
2763 });
2764 if runtime.active_native_sessions() == 0 && sessions_settled {
2765 return Ok(());
2766 }
2767 if started.elapsed() >= shutdown_timeout {
2768 return Err(NodeServerError::ShutdownTimedOut {
2769 active_native_sessions: runtime.active_native_sessions(),
2770 });
2771 }
2772 }
2773 let remainder_elapsed = remainder_start.elapsed();
2774
2775 {
2776 let mut profile = shared
2777 .drive_loop_profile
2778 .lock()
2779 .unwrap_or_else(|poisoned| poisoned.into_inner());
2780 profile.record_runtime_tick(runtime_tick_elapsed);
2781 profile.record_event_drain(event_drain_elapsed);
2782 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2783 profile.record_remainder(remainder_elapsed);
2784 profile.note_iteration(Instant::now());
2785 }
2786
2787 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2799 if tick_result.observations_applied == 0 && events_drained == 0 {
2800 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2801 }
2802
2803 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2808 shared.retire_unavailable_session_records().await;
2809 last_retention_sweep = Instant::now();
2810 }
2811 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2813 for address in shared.take_due_provisional_spawns() {
2814 shared.reap_provisional_spawn(address).await;
2815 }
2816 last_provisional_reap = Instant::now();
2817 }
2818
2819 let hot = shared
2822 .pty_input_marks
2823 .lock()
2824 .unwrap_or_else(|poisoned| poisoned.into_inner())
2825 .values()
2826 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2827 if hot {
2828 sleep(Duration::from_millis(1)).await;
2829 } else {
2830 tokio::select! {
2831 _ = sleep(Duration::from_millis(10)) => {}
2832 _ = shared.pty_input_wake.notified() => {}
2833 }
2834 }
2835 }
2836}
2837
2838#[derive(Clone)]
2839pub struct NodeShutdownHandle {
2840 shared: Arc<NodeShared>,
2841}
2842
2843impl NodeShutdownHandle {
2844 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2845 self.shared
2846 .begin_shutdown()
2847 .await
2848 .map_err(NodeServerError::ShutdownDispatch)
2849 }
2850}
2851
2852async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2853 #[cfg(windows)]
2854 {
2855 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2856 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2857 return tokio::select! {
2858 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2859 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2860 };
2861 }
2862 #[cfg(unix)]
2863 {
2864 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2865 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2866 tokio::select! {
2867 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2869 }
2870 }
2871}
2872
2873fn shell_host_spec() -> AgentSpec {
2887 #[cfg(windows)]
2891 let (detect_command, program, fixed_args, process_name) = {
2892 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2893 r"C:\Windows\System32\cmd.exe".to_owned()
2894 });
2895 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2896 };
2897 #[cfg(not(windows))]
2898 let (detect_command, program, fixed_args, process_name) = (
2899 "bash".to_owned(),
2900 "/bin/bash".to_owned(),
2901 vec!["-i".to_owned()],
2902 "bash".to_owned(),
2903 );
2904 AgentSpec {
2905 id: AgentId::new("shell").expect("shell agent id"),
2906 revision: "hatchery:shell-pty-1".to_owned(),
2907 display_name: "Host shell".to_owned(),
2908 detection: DetectionSpec {
2909 command: detect_command,
2910 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2911 required_commands: Vec::new(),
2912 unsupported_platforms: Vec::new(),
2913 },
2914 launch: LaunchSpec {
2915 program,
2916 fixed_args,
2917 },
2918 expected_processes: vec![ProcessMatcher::Exact {
2919 name: process_name,
2920 }],
2921 prompt: PromptSpec {
2922 initial: InitialPromptMode::None,
2923 native_draft: None,
2924 },
2925 readiness: AgentReadinessSpec::default(),
2926 capabilities: AgentCapabilities {
2927 agent_commands: None,
2928 transports: AgentTransportCapabilities {
2929 pty: true,
2930 pty_adapter: None,
2931 pipe: None,
2932 acp: None,
2933 },
2934 adapters: AgentAdapterCapabilities::default(),
2935 },
2936 verification: SpecVerification::Gate4AgentVerified,
2937 }
2938}
2939
2940fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2941 let mut specs = vec![shell_host_spec()];
2942 specs.extend(
2943 builtin_registry()
2944 .iter()
2945 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2946 .cloned(),
2947 );
2948 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2949}
2950
2951fn provider_contract_manifest(
2952 catalog: &AgentRegistry,
2953) -> Result<
2954 (
2955 Vec<ProviderContractSupport>,
2956 Vec<ProviderAdapterContractSupport>,
2957 ),
2958 NodeServerError,
2959> {
2960 let mut provider_contracts = Vec::new();
2961 let mut provider_adapter_contracts = Vec::new();
2962 for spec in catalog.iter() {
2963 let provider = spec.id.clone();
2964 let revision = ProviderContractRevision::new(spec.revision.clone())
2965 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2966 provider_contracts.push(ProviderContractSupport {
2967 provider: provider.clone(),
2968 revision,
2969 });
2970 for (family, binding) in declared_adapter_bindings(spec)? {
2971 let revision = AdapterContractRevision::new(binding.revision.clone())
2972 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2973 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2974 provider: provider.clone(),
2975 family,
2976 adapter_id: binding.id.clone(),
2977 revision,
2978 });
2979 }
2980 }
2981 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2982 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2983 Ok((provider_contracts, provider_adapter_contracts))
2984}
2985
2986fn declared_adapter_bindings(
2987 spec: &AgentSpec,
2988) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2989 let mut bindings = Vec::new();
2990 push_declared_binding(
2991 &mut bindings,
2992 AdapterFamily::PtySemantic,
2993 spec.capabilities.transports.pty_adapter.as_ref(),
2994 )?;
2995 push_declared_binding(
2996 &mut bindings,
2997 AdapterFamily::Pipe,
2998 spec.capabilities.adapters.pty_sidecar.as_ref(),
2999 )?;
3000 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3001 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3002 gate4agent_types::PipeProtocol::SemanticNdjson
3003 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3004 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3005 });
3006 push_declared_binding(
3007 &mut bindings,
3008 AdapterFamily::Pipe,
3009 pipe_transport
3010 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3011 .map(|transport| &transport.adapter),
3012 )?;
3013 push_declared_binding(
3014 &mut bindings,
3015 AdapterFamily::Acp,
3016 spec.capabilities
3017 .transports
3018 .acp
3019 .as_ref()
3020 .map(|transport| &transport.adapter),
3021 )?;
3022 push_declared_binding(
3023 &mut bindings,
3024 AdapterFamily::Hook,
3025 spec.capabilities.adapters.hook.as_ref(),
3026 )?;
3027 push_declared_binding(
3028 &mut bindings,
3029 AdapterFamily::ManagedHook,
3030 spec.capabilities.adapters.managed_hook.as_ref(),
3031 )?;
3032 push_declared_binding(
3033 &mut bindings,
3034 AdapterFamily::OneShot,
3035 pipe_transport
3036 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3037 .map(|transport| &transport.adapter),
3038 )?;
3039 push_declared_binding(
3040 &mut bindings,
3041 AdapterFamily::OneShot,
3042 spec.capabilities.adapters.one_shot.as_ref(),
3043 )?;
3044 push_declared_binding(
3045 &mut bindings,
3046 AdapterFamily::History,
3047 spec.capabilities.adapters.history.as_ref(),
3048 )?;
3049 push_declared_binding(
3050 &mut bindings,
3051 AdapterFamily::Resume,
3052 spec.capabilities.adapters.resume.as_ref(),
3053 )?;
3054 push_declared_binding(
3055 &mut bindings,
3056 AdapterFamily::SessionOptions,
3057 spec.capabilities.adapters.session_options.as_ref(),
3058 )?;
3059 push_declared_binding(
3060 &mut bindings,
3061 AdapterFamily::CapabilityProbe,
3062 spec.capabilities.adapters.capability_probe.as_ref(),
3063 )?;
3064 Ok(bindings)
3065}
3066
3067fn push_declared_binding<'a>(
3068 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3069 family: AdapterFamily,
3070 binding: Option<&'a AdapterBinding>,
3071) -> Result<(), NodeServerError> {
3072 let Some(binding) = binding else {
3073 return Ok(());
3074 };
3075 if let Some((_, existing)) = bindings
3076 .iter()
3077 .find(|(existing_family, _)| *existing_family == family)
3078 {
3079 if *existing == binding {
3080 return Ok(());
3081 }
3082 return Err(NodeServerError::ProviderContractManifest(format!(
3083 "provider declares conflicting {family:?} adapter bindings"
3084 )));
3085 }
3086 bindings.push((family, binding));
3087 Ok(())
3088}
3089
3090fn merge_durable_state(
3091 configured: &[WorkspaceConfig],
3092 mut loaded: LoadedNodeState,
3093) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3094 let mut workspaces = configured
3095 .iter()
3096 .cloned()
3097 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3098 .collect::<BTreeMap<_, _>>();
3099 let mut roots = workspaces
3100 .values()
3101 .map(|workspace| {
3102 (
3103 platform::root_identity(&workspace.canonical_root),
3104 workspace.workspace_id.clone(),
3105 )
3106 })
3107 .collect::<BTreeMap<_, _>>();
3108 let mut warning = loaded
3109 .warning
3110 .take()
3111 .map(|message| session_registry::sanitized_persistence_summary(&message));
3112
3113 for (workspace_id, canonical_root) in loaded.workspaces {
3114 if let Some(existing) = workspaces.get(&workspace_id) {
3115 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3116 return Err(NodeServerError::DurableState(io::Error::new(
3117 io::ErrorKind::InvalidData,
3118 DURABLE_STATE_CONFLICT_ERROR,
3119 )));
3120 }
3121 continue;
3122 }
3123 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3124 return Err(NodeServerError::DurableState(io::Error::new(
3125 io::ErrorKind::InvalidData,
3126 DURABLE_STATE_CONFLICT_ERROR,
3127 )));
3128 }
3129 if !Path::new(&canonical_root).is_dir() {
3130 warning.get_or_insert_with(|| {
3131 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3132 });
3133 continue;
3134 }
3135 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3136 NodeServerError::DurableState(io::Error::new(
3137 io::ErrorKind::InvalidData,
3138 DURABLE_STATE_CONFLICT_ERROR,
3139 ))
3140 })?;
3141 roots.insert(
3142 platform::root_identity(&workspace.canonical_root),
3143 workspace.workspace_id.clone(),
3144 );
3145 workspaces.insert(workspace_id, workspace);
3146 }
3147
3148 for record in &mut loaded.records {
3149 match workspaces.get(&record.workspace_id) {
3150 Some(workspace)
3151 if platform::roots_equal(
3152 &workspace.canonical_root,
3153 windows_path_text(&record.canonical_root),
3154 ) => {}
3155 Some(_) => {
3156 return Err(NodeServerError::DurableState(io::Error::new(
3157 io::ErrorKind::InvalidData,
3158 DURABLE_STATE_CONFLICT_ERROR,
3159 )));
3160 }
3161 None => {
3162 record.state = ManagedSessionState::Unavailable;
3163 record.active_session = None;
3164 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3165 }
3166 }
3167 }
3168 Ok((workspaces.into_values().collect(), loaded.records, warning))
3169}
3170
3171struct RejectedCommandReason {
3174 message: String,
3175 unsupported_transport: bool,
3182 expires_at: Instant,
3183}
3184
3185struct NodeShared {
3186 handle: Gate4AgentHandle,
3187 access_token: String,
3188 node_id: NodeId,
3189 incarnation_id: NodeIncarnationId,
3190 started_at_unix_ms: u64,
3191 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3192 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3193 managed_worktree_profiles:
3194 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3195 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3196 enabled_providers: Vec<AgentId>,
3197 provider_runtime_statuses: ProviderRuntimeStatuses,
3198 provider_runtime_status_updates:
3199 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3200 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3201 provider_contracts: Vec<ProviderContractSupport>,
3202 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3203 spawn_profiles: SpawnProfileRegistry,
3204 environment_profiles:
3205 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3206 environment_materialization_profiles:
3207 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3208 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3216 #[cfg(feature = "dig2-station-probe")]
3220 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3221 bundle_catalog: RwLock<BundleCatalog>,
3222 delivery_store: Mutex<Option<DeliveryStore>>,
3223 context_pack_store: Mutex<Option<ContextPackStore>>,
3224 context_catalog: RwLock<ContextPackCatalog>,
3225 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3226 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3227 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3228 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3229 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3231 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3233 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3235 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3237 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3239 #[cfg(unix)]
3242 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3243 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3244 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3245 managed_spawn_replays:
3246 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3247 controller: Mutex<Option<ControllerLease>>,
3248 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3251 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3253 history: Mutex<NodeEventHistory>,
3254 event_tx: broadcast::Sender<NodeEventEnvelope>,
3255 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3256 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3273 terminal_frame_watermarks:
3274 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3275 pty_input_wake: tokio::sync::Notify,
3277 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3280 next_connection_id: AtomicU64,
3281 next_instance_id: AtomicU64,
3282 next_command_id: AtomicU64,
3283 shutdown: AtomicBool,
3284 shutdown_notify: Notify,
3285 preauth_slots: Arc<Semaphore>,
3286 authenticated_slots: Arc<Semaphore>,
3287 inspection_slots: Arc<Semaphore>,
3288 mutation_gate: AsyncMutex<()>,
3289 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3290 session_records: Mutex<SessionRecords>,
3291 state_path: Option<PathBuf>,
3292 persistence_error: RwLock<Option<String>>,
3293 state_transaction: Mutex<()>,
3294 input_settle_timeout_ms: u64,
3295 drive_loop_profile: Mutex<DriveLoopProfiler>,
3299 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3305 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3317 native_session_gauge: AtomicUsize,
3324 connection_loop_iterations: AtomicU64,
3335 connection_events_sent: AtomicU64,
3336 rejected_commands_total: AtomicU64,
3345 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3365 drive_loop_iterations_total: AtomicU64,
3366 drive_loop_iterations_idle: AtomicU64,
3367 session_record_retention: SessionRecordRetentionConfig,
3371 retired_records_total: AtomicU64,
3377 #[cfg(feature = "fixture")]
3378 fixture_semantic_hook_policy: bool,
3379 #[cfg(feature = "fixture")]
3380 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3381}
3382
3383impl Drop for NodeShared {
3384 fn drop(&mut self) {
3385 SessionLifecycleHook::on_shutdown(self);
3386 }
3387}
3388
3389#[derive(Clone, Debug, Eq, PartialEq)]
3390struct SessionBinding {
3391 workspace_id: WorkspaceId,
3392 generation: SessionGeneration,
3393 runtime_policy: ProviderRuntimePolicy,
3394 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3395 record_id: Option<SessionRecordId>,
3396 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3397 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3398 bundle: Option<ResolvedBundleReceipt>,
3399 context: Option<ResolvedContextPackReceipt>,
3400 materialization_id: Option<MaterializationId>,
3401}
3402
3403#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3404enum SpawnRecordPolicy {
3405 ProviderIdentityOnly,
3406 Always,
3407}
3408
3409struct NativeEnvironmentSelectionGuard {
3410 control: Option<NativeLaunchProfileControl>,
3411 instance_id: AgentInstanceId,
3412}
3413
3414struct NativeInstanceOverlayGuard {
3415 control: Option<NativeLaunchProfileControl>,
3416 instance_id: AgentInstanceId,
3417}
3418
3419struct NativeHarnessMcpOverlayGuard {
3420 control: NativeLaunchProfileControl,
3421 instance_id: AgentInstanceId,
3422}
3423
3424impl NativeHarnessMcpOverlayGuard {
3425 fn retain(self) { std::mem::forget(self); }
3426}
3427
3428impl Drop for NativeHarnessMcpOverlayGuard {
3429 fn drop(&mut self) {
3430 self.control
3431 .clear_native_mcp_server_launch_overlay(self.instance_id);
3432 }
3433}
3434
3435impl NativeInstanceOverlayGuard {
3436 fn retain(mut self) {
3437 self.control = None;
3438 }
3439}
3440
3441impl Drop for NativeInstanceOverlayGuard {
3442 fn drop(&mut self) {
3443 if let Some(control) = self.control.as_ref() {
3444 control.clear_native_instance_launch_overlay(self.instance_id);
3445 }
3446 }
3447}
3448
3449enum PreparedNativeLaunchOverlay {
3450 Environment(NativeLaunchEnvironmentOverlay),
3451 Instance(NativeInstanceLaunchOverlay),
3452}
3453
3454impl NativeEnvironmentSelectionGuard {
3455 fn retain(mut self) {
3456 self.control = None;
3457 }
3458}
3459
3460impl Drop for NativeEnvironmentSelectionGuard {
3461 fn drop(&mut self) {
3462 if let Some(control) = self.control.as_ref() {
3463 control.clear_native_launch_profile_selection(self.instance_id);
3464 }
3465 }
3466}
3467
3468struct SessionMaterializationGuard<'a> {
3469 shared: &'a NodeShared,
3470 id: Option<MaterializationId>,
3471 provider_home: Option<PathBuf>,
3474}
3475
3476impl SessionMaterializationGuard<'_> {
3477 fn id(&self) -> Option<&MaterializationId> {
3478 self.id.as_ref()
3479 }
3480
3481 fn provider_home(&self) -> Option<&Path> {
3482 self.provider_home.as_deref()
3483 }
3484
3485 fn retain(mut self) {
3486 self.id = None;
3487 }
3488}
3489
3490impl Drop for SessionMaterializationGuard<'_> {
3491 fn drop(&mut self) {
3492 if let Some(id) = self.id.take() {
3493 let _ = self.shared.cleanup_materialization(&id);
3494 }
3495 }
3496}
3497
3498
3499trait SessionLifecycleHook {
3502 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3504 fn on_session_ended(&self, address: &SessionAddress);
3506 fn on_shutdown(&self);
3508 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3510 fn cleanup_session_materialization(
3512 &self,
3513 address: &SessionAddress,
3514 binding: &SessionBinding,
3515 ) -> Result<(), NodeFailure>;
3516}
3517
3518impl SessionLifecycleHook for NodeShared {
3519 fn on_session_ended(&self, address: &SessionAddress) {
3520 self.retire_session_grok_leader(address.session.instance_id);
3521 if !self.shutdown.load(Ordering::Acquire) {
3522 if let Some(file) = self.session_resume_journal() {
3523 crate::pty_resume::remove(&file, address.session.instance_id.0);
3524 }
3525 }
3526 if let Some(accounts) = self.m4a_accounts.as_ref() {
3527 let instance = address.session.instance_id;
3528 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3529 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3530 accounts.remove_session_file(nick);
3531 }
3532 }
3533 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3534 registry.revoke_session(address);
3535 }
3536 if let Some(control) = self.native_launch_profile_control.as_ref() {
3537 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3538 }
3539 }
3540
3541 fn on_shutdown(&self) {
3542 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3543 .into_values()
3544 .collect();
3545 for socket in leaders {
3546 crate::grok_leader::retire(&socket);
3547 }
3548 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549 let instances = registry.shutdown();
3550 if let Some(control) = self.native_launch_profile_control.as_ref() {
3551 for instance_id in instances {
3552 control.clear_native_mcp_server_launch_overlay(instance_id);
3553 }
3554 }
3555 }
3556 }
3557
3558 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3559 if let Some(control) = self.native_launch_profile_control.as_ref() {
3560 control.clear_native_mcp_server_launch_overlay(instance_id);
3561 }
3562 }
3563
3564 fn cleanup_session_materialization(
3565 &self,
3566 address: &SessionAddress,
3567 binding: &SessionBinding,
3568 ) -> Result<(), NodeFailure> {
3569 self.cleanup_session_owned_materialization(address, binding)
3570 }
3571
3572 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3573 #[cfg(feature = "dig2-station-probe")]
3576 {
3577 crate::dig2_station_lease::release_lease_for_holder(
3578 &self.browser_station_leases,
3579 address.session.instance_id,
3580 );
3581 }
3582 if let Some(control) = self.native_launch_profile_control.as_ref() {
3583 control.clear_native_mcp_server_launch_overlay(
3584 address.session.instance_id,
3585 );
3586 }
3587 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3588 registry.revoke_session(address);
3589 }
3590 if removed
3591 .is_some_and(|binding| {
3592 binding.environment_profile.is_some()
3593 || binding.bundle.is_some()
3594 || binding.context.is_some()
3595 })
3596 {
3597 if let Some(control) = self.native_launch_profile_control.as_ref() {
3598 control.clear_native_launch_profile_selection(
3599 address.session.instance_id,
3600 );
3601 control.clear_native_instance_launch_overlay(
3602 address.session.instance_id,
3603 );
3604 }
3605 }
3606 }
3607}
3608
3609struct SpawnPrepareRequest<'r> {
3610 instance_id: AgentInstanceId,
3611 address: &'r SessionAddress,
3612 provider: &'r AgentId,
3613 mode: SessionMode,
3614 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3615 bundle: Option<&'r ResolvedBundleReceipt>,
3616 context: Option<&'r ResolvedContextPackReceipt>,
3617 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3618 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3619 approval_level: ApprovalLevel,
3620 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3621 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3622}
3623
3624struct PreparedSpawn<'a> {
3628 instance_overlay: Option<NativeInstanceOverlayGuard>,
3629 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3630 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3631 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3632 #[cfg(feature = "dig2-station-probe")]
3633 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3634 materialization_id: Option<MaterializationId>,
3635}
3636
3637impl PreparedSpawn<'_> {
3638 fn retain_session_guards(&mut self) {
3640 if let Some(selection) = self.environment_selection.take() {
3641 selection.retain();
3642 }
3643 if let Some(overlay) = self.instance_overlay.take() {
3644 overlay.retain();
3645 }
3646 if let Some(guard) = self.materialization_guard.take() {
3647 guard.retain();
3648 }
3649 }
3650
3651 fn retain_launch_guards(&mut self) {
3653 if let Some(overlay) = self.harness_mcp_overlay.take() {
3654 overlay.retain();
3655 }
3656 #[cfg(feature = "dig2-station-probe")]
3657 if let Some(guard) = self.browser_station_lease_guard.take() {
3658 guard.retain();
3659 }
3660 }
3661}
3662
3663trait SpawnPreparer {
3667 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3668 fn select_environment(
3670 &self,
3671 instance_id: AgentInstanceId,
3672 provider: &AgentId,
3673 mode: SessionMode,
3674 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3675 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3676 fn install_launch_overlay(
3678 &self,
3679 instance_id: AgentInstanceId,
3680 overlay: PreparedNativeLaunchOverlay,
3681 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3682}
3683
3684impl NodeShared {
3685 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3688 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3689 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3690 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3691 if let Some(file) = self.session_resume_journal() {
3692 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3693 }
3694 key
3695 }
3696
3697 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3698 let accounts = self.m4a_accounts.as_ref()?;
3699 let nick = self.m4a_nick_of(instance_id)?;
3700 match accounts.session_workdir(root, provider.as_str(), &nick) {
3701 Ok(dir) => dir,
3702 Err(error) => {
3703 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3704 None
3705 }
3706 }
3707 }
3708
3709 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3710 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3711 }
3712
3713 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3716 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3717 for record in records.records.values_mut() {
3718 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3719 record.display_name = nick.to_owned();
3720 }
3721 }
3722 }
3723
3724 fn m4a_session_overlay(
3727 &self,
3728 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3729 m4a_provider: &str,
3730 provider: &AgentId,
3731 instance_id: AgentInstanceId,
3732 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3733 session_key: &str,
3734 mode: SessionMode,
3735 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3736 let session_key = session_key.to_owned();
3737 if mode == SessionMode::Acp {
3738 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3739 }
3740 Ok( match accounts.provision(m4a_provider, &session_key) {
3741 Ok(account) => {
3742 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3743 let mut identity = account.env_mutations();
3744 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3745 let merged = match environment_overlay {
3746 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3747 .map(PreparedNativeLaunchOverlay::Instance),
3748 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3749 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3750 }
3751 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3752 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3753 }
3754 }
3755 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3756 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3757 let mut merged = merged;
3758 match accounts.write_session_file(&account) {
3759 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3760 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3761 Ok(args) if !args.is_empty() => {
3762 merged = match merged {
3763 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3764 .append_extra_args(args)
3765 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3766 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3767 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3768 .into_instance_with_extra_args(args)
3769 .map(PreparedNativeLaunchOverlay::Instance)
3770 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3771 };
3772 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3773 }
3774 Ok(_) => {}
3775 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3776 },
3777 }
3778 self.m4a_live
3779 .lock()
3780 .unwrap_or_else(|p| p.into_inner())
3781 .insert(account.nick.clone(), (instance_id, provider.clone()));
3782 self.name_record_after_nick(instance_id, &account.nick);
3783 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3784 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3786 Some(merged)
3787 }
3788 Err(error) => {
3789 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3790 environment_overlay
3791 }
3792 })
3793 }
3794}
3795
3796impl NodeShared {
3797 fn m4a_acp_mcp_overlay(
3800 &self,
3801 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3802 m4a_provider: &str,
3803 provider: &AgentId,
3804 instance_id: AgentInstanceId,
3805 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3806 session_key: &str,
3807 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3808 let Some(control) = self.native_launch_profile_control.as_ref() else {
3809 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3810 return Ok(environment_overlay);
3811 };
3812 let account = match accounts.provision(m4a_provider, session_key) {
3813 Ok(a) => a,
3814 Err(error) => {
3815 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3816 return Ok(environment_overlay);
3817 }
3818 };
3819 let session_file = match accounts.write_session_file(&account) {
3820 Ok(p) => p,
3821 Err(error) => {
3822 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3823 return Ok(environment_overlay);
3824 }
3825 };
3826 let env_entries: Vec<(OsString, OsString)> = account
3827 .env_mutations()
3828 .into_iter()
3829 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3830 .filter_map(|m| m.value.map(|v| (m.key, v)))
3831 .collect();
3832 let args: Vec<OsString> = vec![
3833 "--session-file".into(),
3834 session_file.into_os_string(),
3835 "mcp".into(),
3836 "--provider".into(),
3837 provider.as_str().into(),
3838 ];
3839 let overlay = NativeMcpServerLaunchOverlay::new(
3840 provider.clone(),
3841 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3842 accounts.bin().as_os_str().to_os_string(),
3843 args,
3844 env_entries,
3845 Vec::new(),
3846 );
3847 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3848 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3849 Err(error) => {
3850 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3851 return Ok(environment_overlay);
3852 }
3853 }
3854 self.m4a_live
3855 .lock()
3856 .unwrap_or_else(|p| p.into_inner())
3857 .insert(account.nick.clone(), (instance_id, provider.clone()));
3858 self.name_record_after_nick(instance_id, &account.nick);
3859 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3860 Ok(environment_overlay)
3861 }
3862}
3863
3864impl SpawnPreparer for NodeShared {
3865 fn select_environment(
3866 &self,
3867 instance_id: AgentInstanceId,
3868 provider: &AgentId,
3869 mode: SessionMode,
3870 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3871 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3872 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3873 }
3874
3875 fn install_launch_overlay(
3876 &self,
3877 instance_id: AgentInstanceId,
3878 overlay: PreparedNativeLaunchOverlay,
3879 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3880 self.install_prepared_launch_overlay(instance_id, overlay)
3881 }
3882
3883 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3884 let SpawnPrepareRequest {
3885 instance_id,
3886 address,
3887 provider,
3888 mode,
3889 environment_profile,
3890 bundle,
3891 context,
3892 managed_authority,
3893 harness_mcp,
3894 approval_level,
3895 network_allowlist,
3896 browser_profile_id,
3897 } = request;
3898 #[cfg(feature = "dig2-station-probe")]
3903 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3904 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3905 match acquire_lease(
3906 &self.browser_station_leases,
3907 profile_id,
3908 instance_id,
3909 unix_time_ms(),
3910 ) {
3911 Ok(guard) => Some(guard),
3912 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3913 return Err(failure(
3914 NodeFailureCode::BrowserStationProfileBusy,
3915 "dig2browser station profile is already leased by another session",
3916 ));
3917 }
3918 Err(BrowserStationLeaseError::CapacityExceeded) => {
3919 return Err(failure(
3920 NodeFailureCode::BrowserStationProfileBusy,
3921 "dig2browser station profile lease capacity exhausted",
3922 ));
3923 }
3924 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3925 return Err(failure(
3926 NodeFailureCode::BrowserStationProfileBusy,
3927 "session already holds a dig2browser station profile lease",
3928 ));
3929 }
3930 }
3931 } else {
3932 None
3933 };
3934 #[cfg(not(feature = "dig2-station-probe"))]
3935 let _ = browser_profile_id;
3936 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3937 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3946 {
3947 return Err(failure(
3948 NodeFailureCode::BindingMismatch,
3949 "harness MCP reservation does not match the exact PTY/ACP provider",
3950 ));
3951 }
3952 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3953 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3954 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3955 })?;
3956 let launch = prepared.launch();
3961 let mut env_entries = vec![
3962 (
3963 OsString::from(launch.endpoint_env.as_str()),
3964 prepared.endpoint().as_os_str().to_os_string(),
3965 ),
3966 (
3967 OsString::from(launch.token_env.as_str()),
3968 prepared.token().expose().into(),
3969 ),
3970 (
3971 OsString::from(launch.program_env.as_str()),
3972 prepared.helper_program().as_os_str().to_os_string(),
3973 ),
3974 ];
3975 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3976 env_entries.push(trace_entry);
3977 }
3978 if let Some(door) = crate::door::door_listen_addr() {
3979 env_entries.push((
3980 OsString::from("HATCHERY_NODE_DOOR"),
3981 OsString::from(door.to_string()),
3982 ));
3983 let session_key = format!(
3987 "pty-{}-{}-{}",
3988 self.incarnation_id,
3989 address.workspace_id.as_str(),
3990 address.session.instance_id.0
3991 );
3992 env_entries.push((
3993 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3994 OsString::from(session_key),
3995 ));
3996 }
3997 let overlay = NativeMcpServerLaunchOverlay::new(
3998 provider.clone(),
3999 launch.server_name.as_str(),
4000 prepared.helper_program().as_os_str().to_os_string(),
4001 launch.args.iter().map(OsString::from).collect(),
4002 env_entries,
4003 launch.scrub_env.iter().map(OsString::from).collect(),
4004 ).map_err(|_| failure(
4005 NodeFailureCode::HarnessMcpUnavailable,
4006 "harness MCP launch overlay is unavailable",
4007 ))?;
4008 control
4009 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4010 .map_err(|_| failure(
4011 NodeFailureCode::HarnessMcpUnavailable,
4012 "harness MCP launch overlay could not be installed",
4013 ))?;
4014 tracing::info!(
4020 node_id = %self.node_id,
4021 instance_id = ?instance_id,
4022 provider = %provider,
4023 mode = ?mode,
4024 "harness MCP overlay installed for spawn",
4025 );
4026 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4027 } else {
4028 None
4029 };
4030 let prepared_materialization = self.prepare_session_materialization(
4031 &address,
4032 &provider,
4033 mode,
4034 environment_profile,
4035 bundle,
4036 context,
4037 managed_authority.cloned(),
4038 )?;
4039 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4040 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4041 None => (None, None),
4042 };
4043 let materialization_id = materialization_guard
4044 .as_ref()
4045 .and_then(SessionMaterializationGuard::id)
4046 .cloned();
4047 let mut environment_selection = SpawnPreparer::select_environment(self,
4048 instance_id,
4049 &provider,
4050 mode,
4051 environment_profile,
4052 )?;
4053 let provider_home = materialization_guard
4056 .as_ref()
4057 .and_then(SessionMaterializationGuard::provider_home);
4058 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4059 &provider,
4060 mode,
4061 approval_level,
4062 network_allowlist,
4063 provider_home,
4064 )?;
4065 let mut network_overlay_args = network_overlay_args;
4066 {
4068 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4069 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4070 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4071 homes.extend(std::env::split_paths(&extra));
4072 }
4073 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4074 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4075 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4076 }
4077 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4078 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4079 }
4080 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4082 if let Some(file) = self.session_resume_journal() {
4083 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4084 instance_id: instance_id.0,
4085 provider: provider.as_str().to_owned(),
4086 workspace_id: address.workspace_id.as_str().to_owned(),
4087 provider_session_id: String::new(),
4088 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4089 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4090 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4091 cwd: String::new(),
4092 acp: mode == SessionMode::Acp,
4093 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4094 cols: 0,
4095 rows: 0,
4096 });
4097 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4098 }
4099 }
4100 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4101 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4102 }
4103 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4104 (prepared, args) if args.is_empty() => prepared,
4105 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4106 overlay.append_extra_args(args).map_err(|_| {
4107 failure(
4108 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4109 "network allowlist overlay could not merge with instance launch args",
4110 )
4111 })?;
4112 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4113 }
4114 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4115 Some(PreparedNativeLaunchOverlay::Instance(
4116 overlay
4117 .into_instance_with_extra_args(args)
4118 .map_err(|_| {
4119 failure(
4120 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4121 "network allowlist overlay could not merge with environment overlay",
4122 )
4123 })?,
4124 ))
4125 }
4126 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4127 NativeInstanceLaunchOverlay::new(
4128 provider.clone(),
4129 match mode {
4130 SessionMode::Pty => TransportKind::Pty,
4131 SessionMode::Acp => TransportKind::Acp,
4132 SessionMode::Inline => TransportKind::Pipe,
4133 },
4134 Vec::new(),
4135 args,
4136 )
4137 .map_err(|_| {
4138 failure(
4139 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140 "network allowlist overlay is invalid for this transport",
4141 )
4142 })?,
4143 )),
4144 };
4145 #[cfg(unix)]
4148 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4150 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4151 });
4152 #[cfg(unix)]
4153 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4156 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4157 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4158 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4159 let uid = unsafe { libc_geteuid() };
4160 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4161 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4162 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4163 Some(vec![
4164 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4165 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4166 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4167 ])
4168 }
4169 _ => None,
4170 };
4171 #[cfg(not(unix))]
4172 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4173 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4174 (SessionMode::Pty, _, _, Some(identity)) => {
4175 let merged = match environment_overlay {
4176 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4177 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4178 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4179 }
4180 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4181 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4182 }
4183 }
4184 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4185 Some(merged)
4186 }
4187 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4188 let session_key = self.m4a_session_key(instance_id);
4189 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4190 }
4191 (SessionMode::Pty, Some(_), None, _) => {
4192 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4193 environment_overlay
4194 }
4195 _ => environment_overlay,
4196 };
4197 let mut instance_overlay = environment_overlay
4198 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4199 .transpose()?
4200 .flatten();
4201 Ok(PreparedSpawn {
4202 instance_overlay,
4203 environment_selection,
4204 materialization_guard,
4205 harness_mcp_overlay,
4206 #[cfg(feature = "dig2-station-probe")]
4207 browser_station_lease_guard,
4208 materialization_id,
4209 })
4210 }
4211}
4212
4213struct SessionRecords {
4214 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4215}
4216
4217struct SpawnIdempotencyCache {
4218 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4219}
4220
4221struct SpawnIdempotencyEntry {
4222 value: SpawnIdempotencyValue,
4223 expires_at: Instant,
4224}
4225
4226enum SpawnIdempotencyValue {
4227 Standard {
4228 spec: SpawnSpec,
4229 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4230 },
4231 Managed {
4232 request: ManagedWorktreeSpawnRequest,
4233 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4234 },
4235 HarnessMcp,
4236}
4237
4238enum DurableManagedSpawnReplayDecision {
4239 Reserved,
4240 PendingLinked,
4241 Committed(ManagedWorktreeSpawnReceipt),
4242}
4243
4244impl SpawnIdempotencyValue {
4245 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4246 match self {
4247 Self::Standard { result, .. } => result
4248 .as_ref()
4249 .ok()
4250 .and_then(|receipt| receipt.context.as_ref()),
4251 Self::Managed { result, .. } => result
4252 .as_ref()
4253 .ok()
4254 .and_then(|receipt| receipt.spawn.context.as_ref()),
4255 Self::HarnessMcp => None,
4256 }
4257 .is_some_and(|receipt| &receipt.id == context_id)
4258 }
4259}
4260
4261#[derive(Clone, Copy)]
4262struct ControllerLease {
4263 connection_id: u64,
4264 expires_at: Instant,
4265}
4266
4267struct NodeEventHistory {
4268 last_sequence: u64,
4269 replay_floor_sequence: u64,
4270 events: VecDeque<NodeEventEnvelope>,
4271 record_providers: BTreeMap<SessionRecordId, AgentId>,
4272 removed_record_providers: BTreeMap<u64, AgentId>,
4273}
4274
4275impl NodeEventHistory {
4276 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4277 Self {
4278 last_sequence: 0,
4279 replay_floor_sequence: 1,
4280 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4281 record_providers,
4282 removed_record_providers: BTreeMap::new(),
4283 }
4284 }
4285}
4286
4287impl NodeShared {
4288 fn session_resume_journal(&self) -> Option<PathBuf> {
4292 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4293 }
4294
4295 async fn restore_journalled_sessions(&self) {
4298 let Some(file) = self.session_resume_journal() else { return };
4299 if !crate::pty_resume::enabled() { return; }
4300 for e in crate::pty_resume::load(&file) {
4301 crate::pty_resume::remove(&file, e.instance_id);
4302 if !e.session_key.is_empty() {
4304 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4305 }
4306 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4307 if e.acp {
4308 let record_id = {
4309 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4310 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4311 };
4312 let cur = self.next_instance_id.load(Ordering::Acquire);
4313 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4314 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4315 let resumed = match record_id {
4316 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4317 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4318 };
4319 let r = match resumed {
4321 Ok(a) => Ok(a),
4322 Err(err) => {
4323 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4324 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4325 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4326 }
4327 };
4328 match r {
4329 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4330 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4331 }
4332 continue;
4333 }
4334 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4335 let cur = self.next_instance_id.load(Ordering::Acquire);
4337 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4340 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4341 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4342 }
4343 continue;
4344 }
4345 let folder = std::path::Path::new(&e.cwd);
4346 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4347 let name = match (in_session_dir, folder.file_name()) {
4348 (true, Some(n)) => n.to_string_lossy().into_owned(),
4349 _ => format!("{} #{}", e.provider, e.instance_id),
4350 };
4351 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4352 let record = match self.index_provider_session(ws, provider, identity, name) {
4353 Ok(r) => r,
4354 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4355 };
4356 let cur = self.next_instance_id.load(Ordering::Acquire);
4357 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4358 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4359 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4360 match r {
4361 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4362 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4363 }
4364 }
4365 }
4366
4367 fn refresh_session_journal(&self) {
4368 let Some(file) = self.session_resume_journal() else { return };
4369 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4370 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4371 crate::pty_resume::refresh(&file, &homes);
4372 let ids: Vec<(u64, String)> = {
4373 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4374 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4375 };
4376 for (inst, sid) in ids {
4377 crate::pty_resume::set_session_id(&file, inst, &sid);
4378 }
4379 }
4380
4381 fn session_grok_leader_socket(
4382 &self,
4383 provider: &AgentId,
4384 mode: SessionMode,
4385 instance_id: AgentInstanceId,
4386 ) -> Option<std::path::PathBuf> {
4387 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4388 return None;
4389 }
4390 let root = self
4391 .m4a_accounts
4392 .as_ref()
4393 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4394 .unwrap_or_else(std::env::temp_dir);
4395 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4396 if let Some(previous) = self
4397 .grok_leaders
4398 .lock()
4399 .unwrap_or_else(|p| p.into_inner())
4400 .insert(instance_id, socket.clone())
4401 {
4402 crate::grok_leader::retire(&previous);
4403 }
4404 Some(socket)
4405 }
4406
4407 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4409 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4410 if let Some(socket) = socket {
4411 crate::grok_leader::retire(&socket);
4412 }
4413 }
4414
4415 fn new_with_incarnation(
4416 handle: Gate4AgentHandle,
4417 access_token: String,
4418 node_id: NodeId,
4419 incarnation_id: NodeIncarnationId,
4420 workspaces: Vec<WorkspaceConfig>,
4421 enabled_providers: Vec<AgentId>,
4422 provider_runtime_statuses: ProviderRuntimeStatuses,
4423 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4424 provider_contracts: Vec<ProviderContractSupport>,
4425 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4426 spawn_profiles: SpawnProfileRegistry,
4427 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4428 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4429 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4430 state_path: Option<PathBuf>,
4431 records: Vec<ManagedSessionRecord>,
4432 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4433 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4434 materializations: Vec<MaterializationOwnershipRecord>,
4435 persistence_warning: Option<String>,
4436 input_settle_timeout_ms: u64,
4437 ) -> Self {
4438 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4439 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4440 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4441 let (harness_mcp_proxy_event_tx, _) =
4442 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4443 let record_providers = records
4444 .iter()
4445 .map(|record| (record.record_id.clone(), record.provider.clone()))
4446 .collect();
4447 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4448 .iter()
4449 .map(|workspace| {
4450 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4451 })
4452 .collect();
4453 for lease in &managed_worktrees {
4454 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4455 }
4456 let managed_worktree_profiles = workspaces.iter()
4457 .map(|workspace| (
4458 workspace.workspace_id.clone(),
4459 workspace.managed_worktree_profiles.clone(),
4460 ))
4461 .collect();
4462 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4463 managed_worktrees,
4464 managed_worktree_tombstones,
4465 ).expect("durable managed worktree registry was validated while loading");
4466 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4467 managed_worktrees.reattach_record_holders(
4468 &managed_worktree_record_holders(&records),
4469 unix_time_ms(),
4470 );
4471 Self {
4472 handle,
4473 access_token,
4474 node_id,
4475 incarnation_id,
4476 started_at_unix_ms: std::time::SystemTime::now()
4477 .duration_since(std::time::UNIX_EPOCH)
4478 .unwrap_or_default()
4479 .as_millis()
4480 .min(u64::MAX as u128) as u64,
4481 workspaces: RwLock::new(
4482 workspaces
4483 .into_iter()
4484 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4485 .collect(),
4486 ),
4487 worktree_service_modes,
4488 managed_worktree_profiles,
4489 managed_worktrees: Mutex::new(managed_worktrees),
4490 enabled_providers,
4491 provider_runtime_statuses,
4492 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4493 provider_runtime_monitor,
4494 provider_contracts,
4495 provider_adapter_contracts,
4496 spawn_profiles,
4497 environment_profiles: RwLock::new(BTreeMap::new()),
4498 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4499 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4500 #[cfg(feature = "dig2-station-probe")]
4501 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4502 bundle_catalog: RwLock::new(BundleCatalog::default()),
4503 delivery_store: Mutex::new(None),
4504 context_pack_store: Mutex::new(None),
4505 context_catalog: RwLock::new(ContextPackCatalog::default()),
4506 native_launch_profile_control,
4507 harness_mcp_registry: None,
4508 native_session_catalog,
4509 session_environment_materializer,
4510 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4511 m4a_live: Mutex::new(BTreeMap::new()),
4512 m4a_keys: Mutex::new(BTreeMap::new()),
4513 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4514 grok_leaders: Mutex::new(BTreeMap::new()),
4515 #[cfg(unix)]
4516 signer_proxy: std::sync::OnceLock::new(),
4517 materializations: Mutex::new(
4518 materializations
4519 .into_iter()
4520 .map(|record| (record.id().clone(), record))
4521 .collect(),
4522 ),
4523 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4524 entries: BTreeMap::new(),
4525 }),
4526 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4527 controller: Mutex::new(None),
4528 provisional_spawns: Mutex::new(BTreeMap::new()),
4529 pending_acp_options: Mutex::new(BTreeMap::new()),
4530 history: Mutex::new(NodeEventHistory::new(record_providers)),
4531 event_tx,
4532 terminal_event_tx,
4533 agent_stream_event_tx,
4534 harness_mcp_proxy_event_tx,
4535 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4536 pty_input_wake: tokio::sync::Notify::new(),
4537 pty_input_marks: Mutex::new(BTreeMap::new()),
4538 next_connection_id: AtomicU64::new(1),
4539 next_instance_id: AtomicU64::new(1),
4540 next_command_id: AtomicU64::new(1),
4541 shutdown: AtomicBool::new(false),
4542 shutdown_notify: Notify::new(),
4543 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4544 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4545 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4546 mutation_gate: AsyncMutex::new(()),
4547 session_bindings: Mutex::new(BTreeMap::new()),
4548 session_records: Mutex::new(SessionRecords {
4549 records: records
4550 .into_iter()
4551 .map(|record| (record.record_id.clone(), record))
4552 .collect(),
4553 }),
4554 state_path,
4555 persistence_error: RwLock::new(
4556 persistence_warning
4557 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4558 ),
4559 state_transaction: Mutex::new(()),
4560 input_settle_timeout_ms,
4561 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4562 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4563 shell_efficiency_profile: OnceLock::new(),
4564 native_session_gauge: AtomicUsize::new(0),
4565 connection_loop_iterations: AtomicU64::new(0),
4566 connection_events_sent: AtomicU64::new(0),
4567 rejected_commands_total: AtomicU64::new(0),
4568 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4569 drive_loop_iterations_total: AtomicU64::new(0),
4570 drive_loop_iterations_idle: AtomicU64::new(0),
4571 session_record_retention: SessionRecordRetentionConfig::default(),
4572 retired_records_total: AtomicU64::new(0),
4573 #[cfg(feature = "fixture")]
4574 fixture_semantic_hook_policy: false,
4575 #[cfg(feature = "fixture")]
4576 fixture_spawn_managed_worktree_v2_failure_probe:
4577 SpawnManagedWorktreeV2FailureProbe::default(),
4578 }
4579 }
4580
4581 #[cfg(test)]
4582 fn new(
4583 handle: Gate4AgentHandle,
4584 access_token: String,
4585 node_id: NodeId,
4586 workspaces: Vec<WorkspaceConfig>,
4587 enabled_providers: Vec<AgentId>,
4588 ) -> Self {
4589 Self::new_with_incarnation(
4590 handle,
4591 access_token,
4592 node_id,
4593 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4594 workspaces,
4595 enabled_providers,
4596 ProviderRuntimeStatuses::default(),
4597 None,
4598 Vec::new(),
4599 Vec::new(),
4600 SpawnProfileRegistry::default(),
4601 None,
4602 None,
4603 None,
4604 None,
4605 Vec::new(),
4606 Vec::new(),
4607 Vec::new(),
4608 Vec::new(),
4609 None,
4610 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4611 )
4612 }
4613
4614 fn persistence_error(&self) -> Option<String> {
4615 self.persistence_error
4616 .read()
4617 .unwrap_or_else(|poisoned| poisoned.into_inner())
4618 .clone()
4619 }
4620
4621 async fn admit_provider_runtime(
4622 &self,
4623 provider: &AgentId,
4624 requirement: ProviderRuntimeRequirement,
4625 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4626 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4627 let pipe_transport = monitor.supports_pipe_transport(provider);
4628 let acp_transport = monitor.supports_acp_transport(provider);
4629 let provider = provider.clone();
4630 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4631 let (status, admission) = timeout(
4632 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4633 refresh,
4634 )
4635 .await
4636 .map_err(|_| failure(
4637 NodeFailureCode::BackendBusy,
4638 "provider runtime probe exceeded its bounded deadline",
4639 ))?
4640 .map_err(|_| failure(
4641 NodeFailureCode::BackendOperationFailed,
4642 "provider runtime probe task failed",
4643 ))?;
4644 if let Some(status) = status {
4645 self
4646 .provider_runtime_status_updates
4647 .write()
4648 .unwrap_or_else(|poisoned| poisoned.into_inner())
4649 .insert(status.provider().clone(), status);
4650 }
4651 admission.and_then(|policy| {
4652 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4653 })
4654 } else {
4655 crate::provider_runtime::admit_status(
4661 &self.provider_runtime_statuses,
4662 provider,
4663 false,
4664 false,
4665 requirement,
4666 )
4667 };
4668 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4669 admission.map_err(|error| match error {
4670 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4671 NodeFailureCode::BackendOperationFailed,
4672 "provider launcher is unavailable",
4673 ),
4674 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4675 NodeFailureCode::UnsupportedCapability,
4676 "provider semantic capability is not verified",
4677 ),
4678 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4679 NodeFailureCode::BackendBusy,
4680 "provider runtime probe is already in progress",
4681 ),
4682 })
4683 }
4684
4685 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4691 match self.provider_runtime_monitor.as_deref() {
4692 Some(monitor) => monitor.supports_pipe_transport(provider),
4693 None => false,
4694 }
4695 }
4696
4697 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4703 match self.provider_runtime_monitor.as_deref() {
4704 Some(monitor) => monitor.supports_acp_transport(provider),
4705 None => false,
4706 }
4707 }
4708
4709 fn admit_declared_hook_semantics(
4725 &self,
4726 provider: &AgentId,
4727 policy: ProviderRuntimePolicy,
4728 ) -> ProviderRuntimePolicy {
4729 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4730 return policy;
4731 }
4732 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4733 contract.provider == *provider && contract.family == AdapterFamily::Hook
4734 });
4735 if !declares_hook {
4736 return policy;
4737 }
4738 ProviderRuntimePolicy::new(
4739 policy.raw_pty_lifecycle,
4740 policy.semantic_readiness,
4741 policy.structured_prompt,
4742 policy.provider_session_identity,
4743 policy.semantic_resume,
4744 true,
4745 )
4746 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4747 }
4748
4749 fn resolve_network_allowlist_overlay_args(
4764 &self,
4765 provider: &AgentId,
4766 mode: SessionMode,
4767 approval_level: ApprovalLevel,
4768 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4769 provider_home: Option<&Path>,
4770 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4771 let Some(allowlist_id) = network_allowlist else {
4772 return Ok(Vec::new());
4773 };
4774 let catalog = self
4775 .network_allowlist_catalog
4776 .read()
4777 .unwrap_or_else(|poisoned| poisoned.into_inner());
4778 let Some(entry) = catalog.get(allowlist_id) else {
4779 return Err(failure(
4780 NodeFailureCode::UnknownNetworkAllowlist,
4781 "spawn network allowlist is unavailable on this node",
4782 ));
4783 };
4784
4785 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4787 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4788 |message| {
4789 failure(
4790 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4791 &message,
4792 )
4793 },
4794 )?;
4795 if !matches!(mode, SessionMode::Pty) {
4796 return Err(failure(
4797 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4798 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4799 ));
4800 }
4801 let Some(home) = provider_home else {
4802 return Err(failure(
4803 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4804 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4805 ));
4806 };
4807 let domains =
4808 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4809 if domains.is_empty() {
4810 return Err(failure(
4811 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4813 ));
4814 }
4815 let settings_path = home.join(
4816 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4817 );
4818 let json =
4819 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4820 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4821 |_| {
4822 failure(
4823 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4824 "Claude station network settings sidecar could not be written under ProviderHome",
4825 )
4826 },
4827 )?;
4828 let overlay =
4829 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4830 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4831 }
4832
4833 let Some(native) = entry.provider_native.as_ref() else {
4834 return Ok(Vec::new());
4835 };
4836 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4837 provider.as_str(),
4838 approval_level,
4839 native,
4840 )
4841 .map_err(|message| {
4842 failure(
4843 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4844 &message,
4845 )
4846 })?;
4847 if overlay.is_empty() {
4848 return Ok(Vec::new());
4849 }
4850 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4851 return Err(failure(
4852 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853 "Codex network_access -c overlay requires PTY or ACP transport",
4854 ));
4855 }
4856 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4857 }
4858
4859 fn resolve_codex_network_access_overlay_args(
4861 &self,
4862 provider: &AgentId,
4863 mode: SessionMode,
4864 approval_level: ApprovalLevel,
4865 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4866 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4867 self.resolve_network_allowlist_overlay_args(
4868 provider,
4869 mode,
4870 approval_level,
4871 network_allowlist,
4872 None,
4873 )
4874 }
4875
4876 fn resolve_environment_profile(
4877 &self,
4878 resolved: &ResolvedSpawnSpec,
4879 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4880 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4886 let catalog = self
4887 .network_allowlist_catalog
4888 .read()
4889 .unwrap_or_else(|poisoned| poisoned.into_inner());
4890 let Some(entry) = catalog.get(allowlist_id) else {
4891 return Err(failure(
4892 NodeFailureCode::UnknownNetworkAllowlist,
4893 "spawn network allowlist is unavailable on this node",
4894 ));
4895 };
4896 if let Some(native) = entry.provider_native.as_ref() {
4899 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4900 resolved.provider.as_str(),
4901 resolved.approval_level,
4902 native,
4903 ) {
4904 return Err(failure(
4905 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906 &message,
4907 ));
4908 }
4909 }
4910 }
4911 #[cfg(feature = "dig2-station-probe")]
4920 if resolved.browser_profile_id.is_some() {
4921 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4922 match probe_station_reachable() {
4923 Ok(()) => {}
4924 Err(StationProbeError::UnsupportedPlatform)
4925 | Err(StationProbeError::InvalidPipeSuffix) => {
4926 return Err(failure(
4927 NodeFailureCode::BrowserStationProbeUnavailable,
4928 "dig2browser station probe unavailable on this platform",
4929 ));
4930 }
4931 Err(StationProbeError::Unreachable) => {
4932 return Err(failure(
4933 NodeFailureCode::BrowserStationUnreachable,
4934 "dig2browser station named-pipe is unreachable",
4935 ));
4936 }
4937 }
4938 }
4939 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4940 return Ok(None);
4941 };
4942 let profiles = self
4943 .environment_profiles
4944 .read()
4945 .unwrap_or_else(|poisoned| poisoned.into_inner());
4946 let binding = profiles.get(profile_id).ok_or_else(|| {
4947 failure(
4948 NodeFailureCode::UnknownEnvironmentProfile,
4949 "spawn environment profile is unavailable on this node",
4950 )
4951 })?;
4952 if binding.provider != resolved.provider
4953 || binding.native_profile_id(resolved.mode).is_none()
4954 {
4955 return Err(failure(
4956 NodeFailureCode::EnvironmentProfileBindingMismatch,
4957 "spawn environment profile does not match the provider and mode",
4958 ));
4959 }
4960 Ok(Some(ResolvedEnvironmentProfileReceipt {
4964 profile_id: binding.id.clone(),
4965 profile_revision: binding.revision.clone(),
4966 network_allowlist: resolved.network_allowlist.clone(),
4967 browser_profile_id: resolved.browser_profile_id.clone(),
4968 }))
4969 }
4970
4971 fn resolve_bundle(
4972 &self,
4973 resolved: &ResolvedSpawnSpec,
4974 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4975 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4976 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4977 return Ok(None);
4978 };
4979 let catalog = self.bundle_catalog
4980 .read()
4981 .unwrap_or_else(|poisoned| poisoned.into_inner());
4982 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4983 failure(
4984 NodeFailureCode::UnknownBundle,
4985 "spawn bundle is unavailable on this node",
4986 )
4987 })?;
4988 self.bundle_layout(
4989 &resolved.provider,
4990 resolved.mode,
4991 environment_profile,
4992 bundle,
4993 )?;
4994 Ok(Some(bundle.receipt()))
4995 }
4996
4997 fn begin_delivery_stage(
4998 &self,
4999 manifest: DeliveryBundleManifestV2,
5000 ) -> Result<NodeResponse, NodeFailure> {
5001 let mut store = self
5002 .delivery_store
5003 .lock()
5004 .unwrap_or_else(|poisoned| poisoned.into_inner());
5005 let store = store.as_mut().ok_or_else(|| {
5006 failure(
5007 NodeFailureCode::DeliveryStageStorageFailed,
5008 "node delivery store is unavailable",
5009 )
5010 })?;
5011 let begun = store.begin(manifest).map_err(delivery_failure)?;
5012 Ok(NodeResponse::DeliveryStageBegun {
5013 stage_id: begun.stage_id,
5014 manifest_digest: begun.manifest_digest,
5015 missing_blobs: begun.missing_blobs,
5016 })
5017 }
5018
5019 fn put_delivery_blob_chunk(
5020 &self,
5021 stage_id: DeliveryStageId,
5022 blob_digest: DeliveryBlobDigestV1,
5023 offset: u64,
5024 chunk_hex: DeliveryBlobChunkHexV1,
5025 ) -> Result<NodeResponse, NodeFailure> {
5026 let mut store = self
5027 .delivery_store
5028 .lock()
5029 .unwrap_or_else(|poisoned| poisoned.into_inner());
5030 let store = store.as_mut().ok_or_else(|| {
5031 failure(
5032 NodeFailureCode::DeliveryStageStorageFailed,
5033 "node delivery store is unavailable",
5034 )
5035 })?;
5036 let accepted = store
5037 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5038 .map_err(delivery_failure)?;
5039 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5040 stage_id: accepted.stage_id,
5041 blob_digest: accepted.blob_digest,
5042 next_offset: accepted.next_offset,
5043 })
5044 }
5045
5046 fn commit_delivery_stage(
5047 &self,
5048 stage_id: DeliveryStageId,
5049 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5050 let mut store = self
5051 .delivery_store
5052 .lock()
5053 .unwrap_or_else(|poisoned| poisoned.into_inner());
5054 let store = store.as_mut().ok_or_else(|| {
5055 failure(
5056 NodeFailureCode::DeliveryStageStorageFailed,
5057 "node delivery store is unavailable",
5058 )
5059 })?;
5060 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5061 let mut catalog = self
5062 .bundle_catalog
5063 .write()
5064 .unwrap_or_else(|poisoned| poisoned.into_inner());
5065 let mut next_catalog = catalog.clone();
5066 next_catalog
5067 .insert_idempotent(prepared.bundle().clone())
5068 .map_err(|_| {
5069 failure(
5070 NodeFailureCode::DeliveryStageConflict,
5071 "delivery bundle identity conflicts with installed content",
5072 )
5073 })?;
5074 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5075 *catalog = next_catalog;
5076 Ok(receipt)
5077 }
5078
5079 fn abort_delivery_stage(
5080 &self,
5081 stage_id: &DeliveryStageId,
5082 ) -> Result<(), NodeFailure> {
5083 let mut store = self
5084 .delivery_store
5085 .lock()
5086 .unwrap_or_else(|poisoned| poisoned.into_inner());
5087 let store = store.as_mut().ok_or_else(|| {
5088 failure(
5089 NodeFailureCode::DeliveryStageStorageFailed,
5090 "node delivery store is unavailable",
5091 )
5092 })?;
5093 store.abort(stage_id).map_err(delivery_failure)
5094 }
5095
5096 fn resolve_context(
5097 &self,
5098 resolved: &ResolvedSpawnSpec,
5099 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5100 let Some(context_id) = resolved.context_id.as_ref() else {
5101 return Ok(None);
5102 };
5103 self.context_catalog
5104 .read()
5105 .unwrap_or_else(|poisoned| poisoned.into_inner())
5106 .get(context_id)
5107 .map(|context| context.receipt().clone())
5108 .ok_or_else(|| {
5109 failure(
5110 NodeFailureCode::UnknownContextPack,
5111 "spawn context pack is unavailable on this node",
5112 )
5113 })
5114 .map(Some)
5115 }
5116
5117 fn bundle_layout(
5118 &self,
5119 provider: &AgentId,
5120 mode: SessionMode,
5121 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5122 bundle: &NodeBundle,
5123 ) -> Result<BundleProviderLayout, NodeFailure> {
5124 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5125 failure(
5126 NodeFailureCode::BundleBindingMismatch,
5127 "spawn bundle does not match the provider and mode",
5128 )
5129 })?;
5130 if layout != BundleProviderLayout::Codex {
5131 return Ok(layout);
5132 }
5133 let profile = environment_profile.and_then(|receipt| {
5134 let profiles = self
5135 .environment_profiles
5136 .read()
5137 .unwrap_or_else(|poisoned| poisoned.into_inner());
5138 let binding = profiles.get(&receipt.profile_id)?;
5139 if binding.revision != receipt.profile_revision
5140 || &binding.provider != provider
5141 || binding.native_profile_id(mode).is_none()
5142 {
5143 return None;
5144 }
5145 self.environment_materialization_profiles
5146 .read()
5147 .unwrap_or_else(|poisoned| poisoned.into_inner())
5148 .get(&receipt.profile_id)
5149 .cloned()
5150 });
5151 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5152 return Err(failure(
5153 NodeFailureCode::BundleBindingMismatch,
5154 "Codex bundle requires an exact isolated CODEX_HOME profile",
5155 ));
5156 }
5157 Ok(layout)
5158 }
5159
5160 fn select_environment_profile(
5161 &self,
5162 instance_id: AgentInstanceId,
5163 provider: &AgentId,
5164 mode: SessionMode,
5165 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5166 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5167 let Some(environment_profile) = environment_profile else {
5168 return Ok(None);
5169 };
5170 let native_profile_id = {
5171 let profiles = self
5172 .environment_profiles
5173 .read()
5174 .unwrap_or_else(|poisoned| poisoned.into_inner());
5175 let binding = profiles
5176 .get(&environment_profile.profile_id)
5177 .ok_or_else(|| {
5178 failure(
5179 NodeFailureCode::UnknownEnvironmentProfile,
5180 "session environment profile is unavailable on this node",
5181 )
5182 })?;
5183 if &binding.revision != &environment_profile.profile_revision
5184 || &binding.provider != provider
5185 {
5186 return Err(failure(
5187 NodeFailureCode::EnvironmentProfileBindingMismatch,
5188 "session environment profile revision or provider changed",
5189 ));
5190 }
5191 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5192 failure(
5193 NodeFailureCode::EnvironmentProfileBindingMismatch,
5194 "session environment profile does not support this mode",
5195 )
5196 })?
5197 };
5198 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5199 failure(
5200 NodeFailureCode::BackendOperationFailed,
5201 "native environment profile controller is unavailable",
5202 )
5203 })?;
5204 control
5205 .select_native_launch_profile(instance_id, native_profile_id)
5206 .map_err(|_| {
5207 failure(
5208 NodeFailureCode::BackendOperationFailed,
5209 "native environment profile selection failed",
5210 )
5211 })?;
5212 Ok(Some(NativeEnvironmentSelectionGuard {
5213 control: Some(control),
5214 instance_id,
5215 }))
5216 }
5217
5218 fn install_prepared_launch_overlay(
5219 &self,
5220 instance_id: AgentInstanceId,
5221 overlay: PreparedNativeLaunchOverlay,
5222 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5223 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224 failure(
5225 NodeFailureCode::BackendOperationFailed,
5226 "native launch profile controller is unavailable",
5227 )
5228 })?;
5229 match overlay {
5230 PreparedNativeLaunchOverlay::Environment(overlay) => {
5231 control
5232 .install_native_launch_environment_overlay(instance_id, overlay)
5233 .map_err(|_| failure(
5234 NodeFailureCode::BackendOperationFailed,
5235 "session environment overlay installation failed",
5236 ))?;
5237 Ok(None)
5238 }
5239 PreparedNativeLaunchOverlay::Instance(overlay) => {
5240 control
5241 .install_native_instance_launch_overlay(instance_id, overlay)
5242 .map_err(|_| failure(
5243 NodeFailureCode::BundleBindingMismatch,
5244 "session bundle launch overlay installation failed",
5245 ))?;
5246 Ok(Some(NativeInstanceOverlayGuard {
5247 control: Some(control),
5248 instance_id,
5249 }))
5250 }
5251 }
5252 }
5253
5254 fn environment_materialization_profile(
5255 &self,
5256 environment_profile: &ResolvedEnvironmentProfileReceipt,
5257 ) -> Option<NodeSessionMaterializationProfile> {
5258 self.environment_materialization_profiles
5259 .read()
5260 .unwrap_or_else(|poisoned| poisoned.into_inner())
5261 .get(&environment_profile.profile_id)
5262 .cloned()
5263 }
5264
5265 fn materialization_profile(
5266 &self,
5267 provider: &AgentId,
5268 mode: SessionMode,
5269 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5270 bundle: Option<&ResolvedBundleReceipt>,
5271 context: Option<&ResolvedContextPackReceipt>,
5272 ) -> Result<Option<(
5273 NodeSessionMaterializationProfile,
5274 bool,
5275 Option<BundleProviderLayout>,
5276 )>, NodeFailure> {
5277 let environment = environment_profile
5278 .and_then(|receipt| self.environment_materialization_profile(receipt));
5279 let (bundle, bundle_layout) = match bundle {
5280 Some(receipt) => {
5281 let catalog = self.bundle_catalog.read()
5282 .unwrap_or_else(|poisoned| poisoned.into_inner());
5283 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5284 NodeFailureCode::UnknownBundle,
5285 "session bundle is unavailable on this node",
5286 ))?;
5287 if installed.receipt() != *receipt {
5288 return Err(failure(
5289 NodeFailureCode::BundleBindingMismatch,
5290 "session bundle revision or digest changed",
5291 ));
5292 }
5293 let layout = self.bundle_layout(
5294 provider,
5295 mode,
5296 environment_profile,
5297 installed,
5298 )?;
5299 (Some(installed.clone()), Some(layout))
5300 }
5301 None => (None, None),
5302 };
5303 let has_environment_overlay = environment.is_some() || context.is_some();
5304 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5305 (None, None, None) => None,
5306 (Some(profile), None, None) => Some(Ok(profile)),
5307 (None, Some(bundle), Some(layout)) => {
5308 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5309 }
5310 (Some(profile), Some(bundle), Some(layout)) => {
5311 Some(profile.with_bundle(bundle, layout))
5312 }
5313 _ => {
5314 return Err(failure(
5315 NodeFailureCode::BundleBindingMismatch,
5316 "session bundle provider layout is unavailable",
5317 ));
5318 }
5319 };
5320 let profile = profile
5321 .transpose()
5322 .map_err(|_| failure(
5323 NodeFailureCode::BundleMaterializationFailed,
5324 "session bundle materialization profile is invalid",
5325 ))?;
5326 let profile = match (profile, context) {
5327 (Some(profile), Some(receipt)) => {
5328 let catalog = self.context_catalog
5329 .read()
5330 .unwrap_or_else(|poisoned| poisoned.into_inner());
5331 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5332 NodeFailureCode::UnknownContextPack,
5333 "session context pack is unavailable on this node",
5334 ))?;
5335 if pack.receipt() != receipt {
5336 return Err(failure(
5337 NodeFailureCode::ContextPackMaterializationFailed,
5338 "session context pack identity changed",
5339 ));
5340 }
5341 profile.with_context(pack)
5342 }
5343 (None, Some(receipt)) => {
5344 let catalog = self.context_catalog
5345 .read()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner());
5347 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348 NodeFailureCode::UnknownContextPack,
5349 "session context pack is unavailable on this node",
5350 ))?;
5351 if pack.receipt() != receipt {
5352 return Err(failure(
5353 NodeFailureCode::ContextPackMaterializationFailed,
5354 "session context pack identity changed",
5355 ));
5356 }
5357 NodeSessionMaterializationProfile::from_context(pack)
5358 }
5359 (Some(profile), None) => Ok(profile),
5360 (None, None) => return Ok(None),
5361 }
5362 .map_err(|_| failure(
5363 NodeFailureCode::ContextPackMaterializationFailed,
5364 "session context pack materialization profile is invalid",
5365 ))?;
5366 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5367 }
5368
5369 fn prepare_session_materialization<'a>(
5370 &'a self,
5371 address: &SessionAddress,
5372 provider: &AgentId,
5373 mode: SessionMode,
5374 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5375 bundle: Option<&ResolvedBundleReceipt>,
5376 context: Option<&ResolvedContextPackReceipt>,
5377 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5378 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5379 let Some((profile, has_environment_overlay, bundle_layout)) =
5380 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5381 else {
5382 return Ok(None);
5383 };
5384 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5385 failure(
5386 NodeFailureCode::BackendOperationFailed,
5387 "session environment materializer is unavailable",
5388 )
5389 })?;
5390 let materialization_failure_code = if context.is_some() {
5391 NodeFailureCode::ContextPackMaterializationFailed
5392 } else if bundle.is_some() {
5393 NodeFailureCode::BundleMaterializationFailed
5394 } else {
5395 NodeFailureCode::BackendOperationFailed
5396 };
5397 let id = MaterializationId::new(format!(
5398 "mat-{}-{}-{}",
5399 self.incarnation_id,
5400 address.session.instance_id.0,
5401 address.session.generation.0,
5402 ))
5403 .map_err(|_| {
5404 failure(
5405 NodeFailureCode::BackendOperationFailed,
5406 "session environment materialization identity failed",
5407 )
5408 })?;
5409 let owner = MaterializationOwner::Session {
5410 incarnation_id: self.incarnation_id,
5411 instance_id: address.session.instance_id,
5412 generation: address.session.generation,
5413 };
5414 let mut ownership = materializer
5415 .begin(
5416 id.clone(),
5417 environment_profile.cloned(),
5418 bundle.cloned(),
5419 context.cloned(),
5420 owner,
5421 managed_lease_id,
5422 &profile,
5423 unix_time_ms(),
5424 )
5425 .map_err(|_| {
5426 failure(
5427 materialization_failure_code,
5428 "session materialization preparation failed",
5429 )
5430 })?;
5431 {
5432 let _transaction = self
5433 .state_transaction
5434 .lock()
5435 .unwrap_or_else(|poisoned| poisoned.into_inner());
5436 let duplicate = self
5437 .materializations
5438 .lock()
5439 .unwrap_or_else(|poisoned| poisoned.into_inner())
5440 .insert(id.clone(), ownership.clone());
5441 if duplicate.is_some() {
5442 return Err(failure(
5443 NodeFailureCode::BackendOperationFailed,
5444 "session environment materialization identity conflict",
5445 ));
5446 }
5447 if let Err(error) = self.persist_state_locked() {
5448 self.materializations
5449 .lock()
5450 .unwrap_or_else(|poisoned| poisoned.into_inner())
5451 .remove(&id);
5452 return Err(persistence_failure(error));
5453 }
5454 }
5455 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5456 Ok(environment) => environment,
5457 Err(_) => {
5458 let transaction = self
5459 .state_transaction
5460 .lock()
5461 .unwrap_or_else(|poisoned| poisoned.into_inner());
5462 self.materializations
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner())
5465 .insert(id.clone(), ownership.clone());
5466 if self.persist_state_locked().is_err() {
5467 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5468 }
5469 drop(transaction);
5470 if ownership.state() == MaterializationState::CleanupRequired {
5471 let _ = self.cleanup_materialization(&id);
5472 }
5473 return Err(failure(
5474 materialization_failure_code,
5475 "session materialization failed",
5476 ));
5477 }
5478 };
5479 {
5480 let _transaction = self
5481 .state_transaction
5482 .lock()
5483 .unwrap_or_else(|poisoned| poisoned.into_inner());
5484 self.materializations
5485 .lock()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner())
5487 .insert(id.clone(), ownership.clone());
5488 if let Err(error) = self.persist_state_locked() {
5489 drop(_transaction);
5490 let _ = self.cleanup_materialization(&id);
5491 return Err(persistence_failure(error));
5492 }
5493 }
5494 let guard = SessionMaterializationGuard {
5495 shared: self,
5496 id: Some(id),
5497 provider_home: Some(ownership.provider_home().to_path_buf()),
5498 };
5499 let bundle_arguments = match bundle {
5500 Some(receipt) => {
5501 let catalog = self.bundle_catalog.read()
5502 .unwrap_or_else(|poisoned| poisoned.into_inner());
5503 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5504 NodeFailureCode::UnknownBundle,
5505 "session bundle is unavailable on this node",
5506 ))?;
5507 if installed.receipt() != *receipt {
5508 return Err(failure(
5509 NodeFailureCode::BundleBindingMismatch,
5510 "session bundle revision or digest changed",
5511 ));
5512 }
5513 let layout = bundle_layout.ok_or_else(|| failure(
5514 NodeFailureCode::BundleBindingMismatch,
5515 "materialized bundle provider layout is unavailable",
5516 ))?;
5517 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5518 failure(
5519 NodeFailureCode::BundleMaterializationFailed,
5520 "session bundle byte revalidation failed",
5521 )
5522 })?;
5523 Some(bundle_launch_arguments(
5524 layout,
5525 ownership.bundle_root(),
5526 ownership.provider_home(),
5527 ).map_err(|_| failure(
5528 NodeFailureCode::BundleBindingMismatch,
5529 "session bundle launch binding is invalid",
5530 ))?)
5531 }
5532 None => None,
5533 };
5534 if let Some(receipt) = context {
5535 materializer
5536 .revalidate_context(&ownership, receipt)
5537 .map_err(|_| failure(
5538 NodeFailureCode::ContextPackMaterializationFailed,
5539 "session context pack byte revalidation failed",
5540 ))?;
5541 }
5542 let transport = match mode {
5543 SessionMode::Pty => TransportKind::Pty,
5544 SessionMode::Inline => TransportKind::Pipe,
5545 SessionMode::Acp => TransportKind::Acp,
5546 };
5547 let overlay = if let Some(extra_args) = bundle_arguments {
5548 Some(PreparedNativeLaunchOverlay::Instance(
5549 NativeInstanceLaunchOverlay::new(
5550 provider.clone(),
5551 transport,
5552 if has_environment_overlay {
5553 environment
5554 } else {
5555 Vec::new()
5556 },
5557 extra_args,
5558 )
5559 .map_err(|_| {
5560 failure(
5561 NodeFailureCode::BundleBindingMismatch,
5562 "session bundle launch overlay is invalid",
5563 )
5564 })?,
5565 ))
5566 } else if has_environment_overlay {
5567 let context_only = environment_profile.is_none()
5568 && bundle.is_none()
5569 && context.is_some();
5570 let overlay = if context_only {
5571 NativeLaunchEnvironmentOverlay::new_context_root(
5572 provider.clone(),
5573 transport,
5574 environment,
5575 )
5576 .map_err(|_| {
5577 failure(
5578 NodeFailureCode::ContextPackMaterializationFailed,
5579 "context-only launch overlay is invalid",
5580 )
5581 })?
5582 } else {
5583 NativeLaunchEnvironmentOverlay::new(
5584 provider.clone(),
5585 transport,
5586 environment,
5587 )
5588 .map_err(|_| {
5589 failure(
5590 NodeFailureCode::BackendOperationFailed,
5591 "session environment overlay is invalid",
5592 )
5593 })?
5594 };
5595 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5596 } else {
5597 None
5598 };
5599 Ok(Some((overlay, guard)))
5600 }
5601
5602 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5603 let Some(mut ownership) = self
5604 .materializations
5605 .lock()
5606 .unwrap_or_else(|poisoned| poisoned.into_inner())
5607 .get(id)
5608 .cloned()
5609 else {
5610 return Ok(());
5611 };
5612 if ownership.state() != MaterializationState::CleanupRequired {
5613 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5614 failure(
5615 NodeFailureCode::BackendOperationFailed,
5616 "session environment cleanup state is invalid",
5617 )
5618 })?;
5619 }
5620 {
5621 let _transaction = self
5622 .state_transaction
5623 .lock()
5624 .unwrap_or_else(|poisoned| poisoned.into_inner());
5625 let previous = self
5626 .materializations
5627 .lock()
5628 .unwrap_or_else(|poisoned| poisoned.into_inner())
5629 .insert(id.clone(), ownership.clone());
5630 if let Err(error) = self.persist_state_locked() {
5631 let mut materializations = self
5632 .materializations
5633 .lock()
5634 .unwrap_or_else(|poisoned| poisoned.into_inner());
5635 if let Some(previous) = previous {
5636 materializations.insert(id.clone(), previous);
5637 }
5638 return Err(persistence_failure(error));
5639 }
5640 }
5641 let cleanup = self
5642 .session_environment_materializer
5643 .as_ref()
5644 .ok_or(())
5645 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5646 if cleanup.is_err() {
5647 let _ = ownership.mark_recovery_required(unix_time_ms());
5648 self.materializations
5649 .lock()
5650 .unwrap_or_else(|poisoned| poisoned.into_inner())
5651 .insert(id.clone(), ownership);
5652 if self.persist_state().is_err() {
5653 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5654 }
5655 return Err(failure(
5656 NodeFailureCode::BackendOperationFailed,
5657 "session environment cleanup requires recovery",
5658 ));
5659 }
5660 let _transaction = self
5661 .state_transaction
5662 .lock()
5663 .unwrap_or_else(|poisoned| poisoned.into_inner());
5664 self.materializations
5665 .lock()
5666 .unwrap_or_else(|poisoned| poisoned.into_inner())
5667 .remove(id);
5668 if let Err(error) = self.persist_state_locked() {
5669 self.materializations
5670 .lock()
5671 .unwrap_or_else(|poisoned| poisoned.into_inner())
5672 .insert(id.clone(), ownership);
5673 return Err(persistence_failure(error));
5674 }
5675 Ok(())
5676 }
5677
5678 fn resolve_record_materialization(
5679 &self,
5680 record_id: &SessionRecordId,
5681 provider: &AgentId,
5682 mode: SessionMode,
5683 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5684 bundle: Option<&ResolvedBundleReceipt>,
5685 context: Option<&ResolvedContextPackReceipt>,
5686 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5687 let ownership = {
5688 let materializations = self
5689 .materializations
5690 .lock()
5691 .unwrap_or_else(|poisoned| poisoned.into_inner());
5692 let mut matches = materializations.values().filter(|record| {
5693 record.owner()
5694 == &MaterializationOwner::Record {
5695 record_id: record_id.clone(),
5696 }
5697 });
5698 let ownership = matches.next().cloned();
5699 if matches.next().is_some() {
5700 return Err(failure(
5701 NodeFailureCode::BackendOperationFailed,
5702 "managed session has conflicting environment ownership",
5703 ));
5704 }
5705 ownership
5706 };
5707 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5708 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5709 failure(
5710 NodeFailureCode::ContextPackMaterializationFailed,
5711 "session context materializer is unavailable",
5712 )
5713 })?;
5714 let pack = materializer
5715 .revalidate_context(ownership, receipt)
5716 .map_err(|_| {
5717 self.mark_materialization_recovery_required(ownership.id());
5718 failure(
5719 NodeFailureCode::ContextPackMaterializationFailed,
5720 "managed session context pack revalidation failed",
5721 )
5722 })?;
5723 self.context_catalog
5724 .write()
5725 .unwrap_or_else(|poisoned| poisoned.into_inner())
5726 .insert(pack)
5727 .map_err(|_| failure(
5728 NodeFailureCode::ContextPackMaterializationFailed,
5729 "managed session context pack catalog restore failed",
5730 ))?;
5731 }
5732 let materialization = self.materialization_profile(
5733 provider,
5734 mode,
5735 environment_profile,
5736 bundle,
5737 context,
5738 )?;
5739 match (ownership, materialization) {
5740 (None, None) => Ok(None),
5741 (None, Some(_)) | (Some(_), None) => Err(failure(
5742 NodeFailureCode::BackendOperationFailed,
5743 "managed session materialization is unavailable",
5744 )),
5745 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5746 if ownership.environment_profile() != environment_profile
5747 || ownership.bundle() != bundle
5748 || ownership.context() != context
5749 || ownership.state() != MaterializationState::Ready
5750 {
5751 self.mark_materialization_recovery_required(ownership.id());
5752 return Err(failure(
5753 NodeFailureCode::BackendOperationFailed,
5754 "managed session environment ownership requires recovery",
5755 ));
5756 }
5757 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5758 failure(
5759 NodeFailureCode::BackendOperationFailed,
5760 "session environment materializer is unavailable",
5761 )
5762 })?;
5763 if materializer.revalidate(&ownership).is_err() {
5764 self.mark_materialization_recovery_required(ownership.id());
5765 return Err(failure(
5766 NodeFailureCode::BackendOperationFailed,
5767 "managed session environment revalidation failed",
5768 ));
5769 }
5770 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5771 let catalog = self.bundle_catalog.read()
5772 .unwrap_or_else(|poisoned| poisoned.into_inner());
5773 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5774 return Err(failure(
5775 NodeFailureCode::UnknownBundle,
5776 "managed session bundle is unavailable",
5777 ));
5778 };
5779 let layout = bundle_layout.ok_or_else(|| failure(
5780 NodeFailureCode::BundleBindingMismatch,
5781 "record bundle provider layout is unavailable",
5782 ))?;
5783 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5784 drop(catalog);
5785 self.mark_materialization_recovery_required(ownership.id());
5786 return Err(failure(
5787 NodeFailureCode::BundleMaterializationFailed,
5788 "managed session bundle revalidation failed",
5789 ));
5790 }
5791 Some(bundle_launch_arguments(
5792 layout,
5793 ownership.bundle_root(),
5794 ownership.provider_home(),
5795 ).map_err(|_| failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "managed session bundle launch binding is invalid",
5798 ))?)
5799 } else {
5800 None
5801 };
5802 if !has_environment_materialization {
5803 let overlay = bundle_arguments.map(|extra_args| {
5804 NativeInstanceLaunchOverlay::new(
5805 provider.clone(),
5806 TransportKind::Pty,
5807 Vec::new(),
5808 extra_args,
5809 )
5810 .map(PreparedNativeLaunchOverlay::Instance)
5811 .map_err(|_| failure(
5812 NodeFailureCode::BundleBindingMismatch,
5813 "managed session bundle launch overlay is invalid",
5814 ))
5815 }).transpose()?;
5816 return Ok(Some((ownership.id().clone(), overlay)));
5817 }
5818 let environment = match materializer.resolve_environment(&ownership, &profile) {
5819 Ok(environment) => environment,
5820 Err(
5821 SessionEnvironmentMaterializeError::SecretUnavailable
5822 | SessionEnvironmentMaterializeError::SecretDenied
5823 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5824 ) => {
5825 return Err(failure(
5826 NodeFailureCode::BackendOperationFailed,
5827 "managed session environment resolution failed",
5828 ));
5829 }
5830 Err(
5831 SessionEnvironmentMaterializeError::InvalidRoot
5832 | SessionEnvironmentMaterializeError::OwnershipMismatch
5833 | SessionEnvironmentMaterializeError::Filesystem(_)
5834 | SessionEnvironmentMaterializeError::Record(_),
5835 ) => {
5836 self.mark_materialization_recovery_required(ownership.id());
5837 return Err(failure(
5838 NodeFailureCode::BackendOperationFailed,
5839 "managed session environment resolution failed",
5840 ));
5841 }
5842 };
5843 let transport = match mode {
5844 SessionMode::Pty => TransportKind::Pty,
5845 SessionMode::Inline => TransportKind::Pipe,
5846 SessionMode::Acp => TransportKind::Acp,
5847 };
5848 let overlay = match bundle_arguments {
5849 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5850 NativeInstanceLaunchOverlay::new(
5851 provider.clone(),
5852 transport,
5853 environment,
5854 extra_args,
5855 )
5856 .map_err(|_| failure(
5857 NodeFailureCode::BundleBindingMismatch,
5858 "managed session bundle launch overlay is invalid",
5859 ))?,
5860 ),
5861 None => PreparedNativeLaunchOverlay::Environment(
5862 NativeLaunchEnvironmentOverlay::new(
5863 provider.clone(),
5864 transport,
5865 environment,
5866 )
5867 .map_err(|_| {
5868 failure(
5869 NodeFailureCode::BackendOperationFailed,
5870 "managed session environment overlay is invalid",
5871 )
5872 })?,
5873 ),
5874 };
5875 Ok(Some((ownership.id().clone(), Some(overlay))))
5876 }
5877 }
5878 }
5879
5880 fn destroy_record_materialization(
5881 &self,
5882 record_id: &SessionRecordId,
5883 ) -> Result<bool, NodeFailure> {
5884 let ids = self
5885 .materializations
5886 .lock()
5887 .unwrap_or_else(|poisoned| poisoned.into_inner())
5888 .values()
5889 .filter_map(|record| {
5890 (record.owner()
5891 == &MaterializationOwner::Record {
5892 record_id: record_id.clone(),
5893 })
5894 .then(|| record.id().clone())
5895 })
5896 .collect::<Vec<_>>();
5897 if ids.len() > 1 {
5898 return Err(failure(
5899 NodeFailureCode::BackendOperationFailed,
5900 "managed session has conflicting environment ownership",
5901 ));
5902 }
5903 let Some(id) = ids.first().cloned() else {
5904 return Ok(false);
5905 };
5906 let (terminal_record, mut cleanup_ownership) = {
5907 let _transaction = self
5908 .state_transaction
5909 .lock()
5910 .unwrap_or_else(|poisoned| poisoned.into_inner());
5911 let original_record = self.record(record_id)?;
5912 let mut terminal_record = original_record.clone();
5913 terminal_record.provider_session = None;
5914 terminal_record.active_session = None;
5915 terminal_record.state = ManagedSessionState::Unavailable;
5916 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5917 terminal_record.updated_at_unix_ms = unix_time_ms();
5918 let original_ownership = self
5919 .materializations
5920 .lock()
5921 .unwrap_or_else(|poisoned| poisoned.into_inner())
5922 .get(&id)
5923 .cloned()
5924 .ok_or_else(|| {
5925 failure(
5926 NodeFailureCode::BackendOperationFailed,
5927 "managed session environment ownership disappeared during cleanup",
5928 )
5929 })?;
5930 let mut cleanup_ownership = original_ownership.clone();
5931 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5932 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5933 failure(
5934 NodeFailureCode::BackendOperationFailed,
5935 "managed session environment cleanup state is invalid",
5936 )
5937 })?;
5938 }
5939 self.session_records
5940 .lock()
5941 .unwrap_or_else(|poisoned| poisoned.into_inner())
5942 .records
5943 .insert(record_id.clone(), terminal_record.clone());
5944 self.materializations
5945 .lock()
5946 .unwrap_or_else(|poisoned| poisoned.into_inner())
5947 .insert(id.clone(), cleanup_ownership.clone());
5948 if let Err(error) = self.persist_state_locked() {
5949 self.session_records
5950 .lock()
5951 .unwrap_or_else(|poisoned| poisoned.into_inner())
5952 .records
5953 .insert(record_id.clone(), original_record);
5954 self.materializations
5955 .lock()
5956 .unwrap_or_else(|poisoned| poisoned.into_inner())
5957 .insert(id.clone(), original_ownership);
5958 return Err(persistence_failure(error));
5959 }
5960 (terminal_record, cleanup_ownership)
5961 };
5962 let cleanup = self
5963 .session_environment_materializer
5964 .as_ref()
5965 .ok_or(())
5966 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5967 if cleanup.is_err() {
5968 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5969 self.materializations
5970 .lock()
5971 .unwrap_or_else(|poisoned| poisoned.into_inner())
5972 .insert(id.clone(), cleanup_ownership);
5973 if self.persist_state().is_err() {
5974 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5975 }
5976 return Err(failure(
5977 NodeFailureCode::BackendOperationFailed,
5978 "managed session environment cleanup requires recovery",
5979 ));
5980 }
5981 let _transaction = self
5982 .state_transaction
5983 .lock()
5984 .unwrap_or_else(|poisoned| poisoned.into_inner());
5985 self.remove_record_memory(record_id);
5986 self.materializations
5987 .lock()
5988 .unwrap_or_else(|poisoned| poisoned.into_inner())
5989 .remove(&id);
5990 if let Err(error) = self.persist_state_locked() {
5991 self.session_records
5992 .lock()
5993 .unwrap_or_else(|poisoned| poisoned.into_inner())
5994 .records
5995 .insert(record_id.clone(), terminal_record);
5996 self.materializations
5997 .lock()
5998 .unwrap_or_else(|poisoned| poisoned.into_inner())
5999 .insert(id, cleanup_ownership);
6000 return Err(persistence_failure(error));
6001 }
6002 Ok(true)
6003 }
6004
6005 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6006 let changed = {
6007 let mut materializations = self
6008 .materializations
6009 .lock()
6010 .unwrap_or_else(|poisoned| poisoned.into_inner());
6011 let Some(ownership) = materializations.get_mut(id) else {
6012 return;
6013 };
6014 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6015 };
6016 if changed && self.persist_state().is_err() {
6017 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6018 }
6019 }
6020
6021 fn reconcile_materializations(&self) {
6022 let records = self
6023 .materializations
6024 .lock()
6025 .unwrap_or_else(|poisoned| poisoned.into_inner())
6026 .values()
6027 .cloned()
6028 .collect::<Vec<_>>();
6029 for mut ownership in records {
6030 let result = match (ownership.state(), ownership.owner()) {
6031 (MaterializationState::Preparing, _)
6032 | (MaterializationState::CleanupRequired, _) => {
6033 self.cleanup_materialization(ownership.id())
6034 }
6035 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6036 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6037 self.materializations
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner())
6040 .insert(ownership.id().clone(), ownership.clone());
6041 self.persist_state().map_err(persistence_failure)
6042 } else {
6043 Err(failure(
6044 NodeFailureCode::BackendOperationFailed,
6045 "session environment reconciliation failed",
6046 ))
6047 }
6048 }
6049 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6050 .session_environment_materializer
6051 .as_ref()
6052 .ok_or_else(|| {
6053 failure(
6054 NodeFailureCode::BackendOperationFailed,
6055 "session environment materializer is unavailable",
6056 )
6057 })
6058 .and_then(|materializer| {
6059 materializer.revalidate(&ownership).map_err(|_| failure(
6060 NodeFailureCode::BackendOperationFailed,
6061 "session materialization revalidation failed",
6062 ))?;
6063 let record = self.record(record_id)?;
6064 if record.environment_profile.as_ref() != ownership.environment_profile()
6065 || record.bundle.as_ref() != ownership.bundle()
6066 || record.context.as_ref() != ownership.context()
6067 {
6068 return Err(failure(
6069 NodeFailureCode::BackendOperationFailed,
6070 "session materialization ownership changed after restart",
6071 ));
6072 }
6073 if let Some(receipt) = ownership.bundle() {
6074 let catalog = self.bundle_catalog.read()
6075 .unwrap_or_else(|poisoned| poisoned.into_inner());
6076 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6077 NodeFailureCode::UnknownBundle,
6078 "session bundle is unavailable after restart",
6079 ))?;
6080 if bundle.receipt() != *receipt {
6081 return Err(failure(
6082 NodeFailureCode::BundleBindingMismatch,
6083 "session bundle changed after restart",
6084 ));
6085 }
6086 let layout = self.bundle_layout(
6087 &record.provider,
6088 record.mode,
6089 record.environment_profile.as_ref(),
6090 bundle,
6091 )?;
6092 materializer
6093 .revalidate_bundle(&ownership, bundle, layout)
6094 .map_err(|_| failure(
6095 NodeFailureCode::BundleMaterializationFailed,
6096 "session bundle revalidation failed",
6097 ))?;
6098 }
6099 if let Some(receipt) = ownership.context() {
6100 let pack = materializer
6101 .revalidate_context(&ownership, receipt)
6102 .map_err(|_| {
6103 failure(
6104 NodeFailureCode::ContextPackMaterializationFailed,
6105 "session context revalidation failed",
6106 )
6107 })?;
6108 self.context_catalog
6109 .write()
6110 .unwrap_or_else(|poisoned| poisoned.into_inner())
6111 .insert(pack)
6112 .map_err(|_| {
6113 failure(
6114 NodeFailureCode::ContextPackMaterializationFailed,
6115 "session context catalog restore failed",
6116 )
6117 })?;
6118 }
6119 Ok(())
6120 }),
6121 (MaterializationState::RecoveryRequired, _) => Ok(()),
6122 };
6123 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6124 let _ = ownership.mark_recovery_required(unix_time_ms());
6125 self.materializations
6126 .lock()
6127 .unwrap_or_else(|poisoned| poisoned.into_inner())
6128 .insert(ownership.id().clone(), ownership);
6129 if self.persist_state().is_err() {
6130 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6131 }
6132 }
6133 }
6134 let materialized_profiles = self
6135 .environment_materialization_profiles
6136 .read()
6137 .unwrap_or_else(|poisoned| poisoned.into_inner())
6138 .keys()
6139 .cloned()
6140 .collect::<Vec<_>>();
6141 let ownership = self
6142 .materializations
6143 .lock()
6144 .unwrap_or_else(|poisoned| poisoned.into_inner())
6145 .values()
6146 .cloned()
6147 .collect::<Vec<_>>();
6148 let changed = {
6149 let mut session_records = self
6150 .session_records
6151 .lock()
6152 .unwrap_or_else(|poisoned| poisoned.into_inner());
6153 let mut changed = false;
6154 for record in session_records.records.values_mut() {
6155 let environment_profile = record.environment_profile.as_ref();
6156 if environment_profile.is_none()
6157 && record.bundle.is_none()
6158 && record.context.is_none()
6159 {
6160 continue;
6161 }
6162 if record.bundle.is_none()
6163 && environment_profile.is_some_and(|profile| {
6164 !materialized_profiles.contains(&profile.profile_id)
6165 })
6166 {
6167 continue;
6168 }
6169 let has_exact_ownership = ownership.iter().any(|candidate| {
6170 candidate.state() == MaterializationState::Ready
6171 && candidate.environment_profile() == environment_profile
6172 && candidate.bundle() == record.bundle.as_ref()
6173 && candidate.context() == record.context.as_ref()
6174 && candidate.owner()
6175 == &MaterializationOwner::Record {
6176 record_id: record.record_id.clone(),
6177 }
6178 });
6179 if !has_exact_ownership {
6180 record.provider_session = None;
6181 record.active_session = None;
6182 record.state = ManagedSessionState::Unavailable;
6183 record.last_error = Some(if record.bundle.is_some() {
6184 "bundle-unavailable".to_owned()
6185 } else if record.context.is_some() {
6186 "context-unavailable".to_owned()
6187 } else {
6188 "environment-profile-unavailable".to_owned()
6189 });
6190 record.updated_at_unix_ms = unix_time_ms();
6191 changed = true;
6192 }
6193 }
6194 changed
6195 };
6196 if changed && self.persist_state().is_err() {
6197 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6198 }
6199 }
6200
6201 fn replay_spawn_spec(
6202 &self,
6203 spec: &SpawnSpec,
6204 now: Instant,
6205 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6206 let mut cache = self
6207 .spawn_idempotency
6208 .lock()
6209 .unwrap_or_else(|poisoned| poisoned.into_inner());
6210 cache.entries.retain(|_, entry| entry.expires_at > now);
6211 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6212 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6213 return Err(failure(
6214 NodeFailureCode::SpawnIdempotencyConflict,
6215 "spawn idempotency key was reused across spawn kinds",
6216 ));
6217 };
6218 if existing != spec {
6219 return Err(failure(
6220 NodeFailureCode::SpawnIdempotencyConflict,
6221 "spawn idempotency key was reused with a different specification",
6222 ));
6223 }
6224 return Ok(Some(result.clone()));
6225 }
6226 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6227 return Err(failure(
6228 NodeFailureCode::SpawnIdempotencyCapacity,
6229 "spawn idempotency cache reached its bounded capacity",
6230 ));
6231 }
6232 Ok(None)
6233 }
6234
6235 fn remember_spawn_spec(
6236 &self,
6237 spec: SpawnSpec,
6238 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6239 accepted_at: Instant,
6240 ) {
6241 self
6242 .spawn_idempotency
6243 .lock()
6244 .unwrap_or_else(|poisoned| poisoned.into_inner())
6245 .entries
6246 .insert(
6247 spec.idempotency_key.clone(),
6248 SpawnIdempotencyEntry {
6249 value: SpawnIdempotencyValue::Standard { spec, result },
6250 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6251 },
6252 );
6253 }
6254
6255 fn claim_harness_mcp_spawn(
6256 &self,
6257 _reservation_id: &HarnessMcpReservationId,
6258 _activation_digest: &HarnessMcpActivationDigest,
6259 spec: &SpawnSpec,
6260 now: Instant,
6261 ) -> Result<(), NodeFailure> {
6262 let mut cache = self.spawn_idempotency.lock()
6263 .unwrap_or_else(|poisoned| poisoned.into_inner());
6264 cache.entries.retain(|_, entry| entry.expires_at > now);
6265 if cache.entries.contains_key(&spec.idempotency_key) {
6266 return Err(failure(
6267 NodeFailureCode::SpawnIdempotencyConflict,
6268 "spawn idempotency key was reused across spawn kinds",
6269 ));
6270 }
6271 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6272 return Err(failure(
6273 NodeFailureCode::SpawnIdempotencyCapacity,
6274 "spawn idempotency cache reached its bounded capacity",
6275 ));
6276 }
6277 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6278 value: SpawnIdempotencyValue::HarnessMcp,
6279 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280 });
6281 Ok(())
6282 }
6283
6284 fn replay_managed_spawn(
6285 &self,
6286 request: &ManagedWorktreeSpawnRequest,
6287 now: Instant,
6288 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6289 let mut cache = self.spawn_idempotency.lock()
6290 .unwrap_or_else(|poisoned| poisoned.into_inner());
6291 cache.entries.retain(|_, entry| entry.expires_at > now);
6292 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6293 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6294 return Err(failure(
6295 NodeFailureCode::SpawnIdempotencyConflict,
6296 "spawn idempotency key was reused across spawn kinds",
6297 ));
6298 };
6299 if existing != request {
6300 return Err(failure(
6301 NodeFailureCode::SpawnIdempotencyConflict,
6302 "spawn idempotency key was reused with a different managed specification",
6303 ));
6304 }
6305 return Ok(Some(result.clone()));
6306 }
6307 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308 return Err(failure(
6309 NodeFailureCode::SpawnIdempotencyCapacity,
6310 "spawn idempotency cache reached its bounded capacity",
6311 ));
6312 }
6313 Ok(None)
6314 }
6315
6316 fn remember_managed_spawn(
6317 &self,
6318 request: ManagedWorktreeSpawnRequest,
6319 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6320 accepted_at: Instant,
6321 ) {
6322 self.spawn_idempotency.lock()
6323 .unwrap_or_else(|poisoned| poisoned.into_inner())
6324 .entries
6325 .insert(
6326 request.spawn_spec.idempotency_key.clone(),
6327 SpawnIdempotencyEntry {
6328 value: SpawnIdempotencyValue::Managed { request, result },
6329 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6330 },
6331 );
6332 }
6333
6334 fn remember_managed_spawn_attempt(
6335 &self,
6336 durable_v2_key: Option<&SpawnIdempotencyKey>,
6337 request: ManagedWorktreeSpawnRequest,
6338 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6339 accepted_at: Instant,
6340 ) {
6341 if durable_v2_key.is_none() {
6342 self.remember_managed_spawn(request, result, accepted_at);
6343 }
6344 }
6345
6346 fn acquire_durable_managed_spawn_v2(
6347 &self,
6348 request: &ManagedWorktreeSpawnRequestV2,
6349 request_digest: String,
6350 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6351 if self.state_path.is_none() {
6352 return Err(failure(
6353 NodeFailureCode::BackendOperationFailed,
6354 "managed worktree V2 requires durable node state",
6355 ));
6356 }
6357 let _transaction = self.state_transaction
6358 .lock()
6359 .unwrap_or_else(|poisoned| poisoned.into_inner());
6360 let key = request.spawn_spec.idempotency_key.clone();
6361 let mut previous = None;
6362 {
6363 let mut replays = self.managed_spawn_replays
6364 .lock()
6365 .unwrap_or_else(|poisoned| poisoned.into_inner());
6366 if let Some(record) = replays.get_mut(&key) {
6367 if record.request_digest != request_digest {
6368 return Err(failure(
6369 NodeFailureCode::SpawnIdempotencyConflict,
6370 "managed worktree V2 idempotency key was reused with a different request",
6371 ));
6372 }
6373 match &record.state {
6374 ManagedWorktreeSpawnReplayStateV10::Pending {
6375 owner_incarnation_id,
6376 lease_id: None,
6377 } if owner_incarnation_id != &self.incarnation_id => {
6378 previous = Some(record.clone());
6379 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6380 owner_incarnation_id: self.incarnation_id,
6381 lease_id: None,
6382 };
6383 record.updated_at_unix_ms = unix_time_ms();
6384 }
6385 ManagedWorktreeSpawnReplayStateV10::Pending {
6386 lease_id: None,
6387 ..
6388 } => {
6389 return Err(failure(
6390 NodeFailureCode::BackendBusy,
6391 "managed worktree V2 reservation is already active",
6392 ));
6393 }
6394 ManagedWorktreeSpawnReplayStateV10::Pending {
6395 lease_id: Some(_),
6396 ..
6397 } => {
6398 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6399 }
6400 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6401 return Ok(DurableManagedSpawnReplayDecision::Committed(
6402 receipt.clone(),
6403 ));
6404 }
6405 }
6406 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6407 return Err(failure(
6408 NodeFailureCode::SpawnIdempotencyCapacity,
6409 "durable managed worktree replay ledger reached its bounded capacity",
6410 ));
6411 } else {
6412 let now = unix_time_ms();
6413 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6414 idempotency_key: key.clone(),
6415 request_digest,
6416 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6417 profile_id: request.worktree_profile_id.clone(),
6418 expected_profile_revision: request.expected_profile_revision.clone(),
6419 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6420 owner_incarnation_id: self.incarnation_id,
6421 lease_id: None,
6422 },
6423 created_at_unix_ms: now,
6424 updated_at_unix_ms: now,
6425 });
6426 }
6427 }
6428 if let Err(error) = self.persist_state_locked() {
6429 let mut replays = self.managed_spawn_replays
6430 .lock()
6431 .unwrap_or_else(|poisoned| poisoned.into_inner());
6432 match previous {
6433 Some(previous) => {
6434 replays.insert(key, previous);
6435 }
6436 None => {
6437 replays.remove(&key);
6438 }
6439 }
6440 return Err(persistence_failure(error));
6441 }
6442 Ok(DurableManagedSpawnReplayDecision::Reserved)
6443 }
6444
6445 fn link_durable_managed_spawn_v2_lease_locked(
6446 &self,
6447 key: &SpawnIdempotencyKey,
6448 lease_id: &ManagedWorktreeLeaseId,
6449 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6450 let mut replays = self.managed_spawn_replays
6451 .lock()
6452 .unwrap_or_else(|poisoned| poisoned.into_inner());
6453 let record = replays.get_mut(key).ok_or_else(|| {
6454 failure(
6455 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6456 "managed worktree V2 reservation disappeared before allocation",
6457 )
6458 })?;
6459 let previous = record.clone();
6460 match &mut record.state {
6461 ManagedWorktreeSpawnReplayStateV10::Pending {
6462 lease_id: current,
6463 ..
6464 }
6465 if current.is_none() =>
6466 {
6467 *current = Some(lease_id.clone());
6468 record.updated_at_unix_ms = unix_time_ms();
6469 Ok(previous)
6470 }
6471 _ => Err(failure(
6472 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6473 "managed worktree V2 reservation is not allocation-ready",
6474 )),
6475 }
6476 }
6477
6478 fn restore_durable_managed_spawn_v2_locked(
6479 &self,
6480 key: &SpawnIdempotencyKey,
6481 previous: ManagedWorktreeSpawnReplayRecordV10,
6482 ) {
6483 self.managed_spawn_replays
6484 .lock()
6485 .unwrap_or_else(|poisoned| poisoned.into_inner())
6486 .insert(key.clone(), previous);
6487 }
6488
6489 fn commit_durable_managed_spawn_v2(
6490 &self,
6491 request: &ManagedWorktreeSpawnRequestV2,
6492 receipt: ManagedWorktreeSpawnReceipt,
6493 ) -> Result<(), NodeFailure> {
6494 let _transaction = self.state_transaction
6495 .lock()
6496 .unwrap_or_else(|poisoned| poisoned.into_inner());
6497 let key = &request.spawn_spec.idempotency_key;
6498 let previous = {
6499 let mut replays = self.managed_spawn_replays
6500 .lock()
6501 .unwrap_or_else(|poisoned| poisoned.into_inner());
6502 let record = replays.get_mut(key).ok_or_else(|| {
6503 failure(
6504 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6505 "managed worktree V2 reservation disappeared before commit",
6506 )
6507 })?;
6508 let previous = record.clone();
6509 match &record.state {
6510 ManagedWorktreeSpawnReplayStateV10::Pending {
6511 lease_id: Some(lease_id),
6512 ..
6513 } if lease_id == &receipt.lease.lease_id => {}
6514 _ => {
6515 return Err(failure(
6516 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6517 "managed worktree V2 reservation is not linked to the spawned lease",
6518 ));
6519 }
6520 }
6521 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6522 record.updated_at_unix_ms = unix_time_ms();
6523 previous
6524 };
6525 if let Err(error) = self.persist_state_locked() {
6526 self.restore_durable_managed_spawn_v2_locked(key, previous);
6527 return Err(persistence_failure(error));
6528 }
6529 Ok(())
6530 }
6531
6532 fn remove_unallocated_durable_managed_spawn_v2(
6533 &self,
6534 request: &ManagedWorktreeSpawnRequestV2,
6535 ) -> Result<(), NodeFailure> {
6536 let _transaction = self.state_transaction
6537 .lock()
6538 .unwrap_or_else(|poisoned| poisoned.into_inner());
6539 let key = &request.spawn_spec.idempotency_key;
6540 let removed = {
6541 let mut replays = self.managed_spawn_replays
6542 .lock()
6543 .unwrap_or_else(|poisoned| poisoned.into_inner());
6544 match replays.get(key).map(|record| &record.state) {
6545 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6546 lease_id: None,
6547 ..
6548 }) => {
6549 replays.remove(key)
6550 }
6551 _ => None,
6552 }
6553 };
6554 let Some(removed) = removed else {
6555 return Ok(());
6556 };
6557 if let Err(error) = self.persist_state_locked() {
6558 self.managed_spawn_replays
6559 .lock()
6560 .unwrap_or_else(|poisoned| poisoned.into_inner())
6561 .insert(key.clone(), removed);
6562 return Err(persistence_failure(error));
6563 }
6564 Ok(())
6565 }
6566
6567 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6568 if spec.target.node_id != self.node_id {
6569 return Err(failure(
6570 NodeFailureCode::SpawnTargetMismatch,
6571 "spawn target does not match this node",
6572 ));
6573 }
6574 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6575 failure(
6576 NodeFailureCode::UnknownSpawnProfile,
6577 "spawn profile is unavailable on this node",
6578 )
6579 })?;
6580 let resolved = spec.resolve(defaults).map_err(|error| match error {
6581 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6582 NodeFailureCode::SpawnProfileRevisionMismatch,
6583 "spawn profile revision does not match the loaded profile",
6584 ),
6585 _ => failure(
6586 NodeFailureCode::InvalidRequest,
6587 "spawn specification could not be resolved",
6588 ),
6589 })?;
6590 if resolved.target.worktree_id.is_some() {
6591 self.require_worktree_service(&resolved.target.workspace_id)?;
6592 }
6593 let environment_profile = self.resolve_environment_profile(&resolved)?;
6594 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6595 self.resolve_context(&resolved)?;
6596 Ok(resolved)
6597 }
6598
6599 async fn resolve_spawn_workspace(
6600 &self,
6601 resolved: &ResolvedSpawnSpec,
6602 deadline: Instant,
6603 ) -> Result<WorkspaceId, NodeFailure> {
6604 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6605 return Ok(resolved.target.workspace_id.clone());
6606 };
6607 if self.managed_worktrees.lock()
6608 .unwrap_or_else(|poisoned| poisoned.into_inner())
6609 .lease_for_workspace(worktree_id)
6610 .is_some()
6611 {
6612 return Err(failure(
6613 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6614 "managed worktree targets may only be spawned through their lease request",
6615 ));
6616 }
6617 self.require_worktree_service(&resolved.target.workspace_id)?;
6618 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6619 let selected_root = self.workspace_root(worktree_id)?;
6620 let remaining = spawn_deadline_remaining(deadline)?;
6621 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6622 .await
6623 .map_err(|_| {
6624 failure(
6625 NodeFailureCode::SpawnDeadlineExceeded,
6626 "Git worktree selection exceeded the spawn deadline",
6627 )
6628 })?
6629 .map_err(git_worktree_failure)?;
6630 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6631 Ok(worktree_id.clone())
6632 }
6633
6634 async fn spawn_from_spec(
6635 &self,
6636 spec: SpawnSpec,
6637 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6638 self.spawn_from_spec_with_proxy(spec, None).await
6639 }
6640
6641 async fn spawn_from_spec_with_proxy(
6642 &self,
6643 spec: SpawnSpec,
6644 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6645 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6646 let accepted_at = Instant::now();
6647 if harness_mcp.is_none() {
6648 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6649 return replayed;
6650 }
6651 }
6652 let resolved = self.resolve_spawn_spec(&spec)?;
6653 let environment_profile = self.resolve_environment_profile(&resolved)?;
6654 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6655 let context = self.resolve_context(&resolved)?;
6656 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6657 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6658 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6659 let spawn_workspace_id = match spawn_workspace_id {
6660 Ok(workspace_id) => workspace_id,
6661 Err(error) => {
6662 let result = Err(error);
6663 if harness_mcp.is_none() {
6664 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6665 }
6666 return result;
6667 }
6668 };
6669 if self.managed_worktrees.lock()
6670 .unwrap_or_else(|poisoned| poisoned.into_inner())
6671 .lease_for_workspace(&spawn_workspace_id)
6672 .is_some()
6673 {
6674 let result = Err(failure(
6675 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6676 "managed worktree targets may only be spawned through their lease request",
6677 ));
6678 if harness_mcp.is_none() {
6679 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6680 }
6681 return result;
6682 }
6683 let result = self
6684 .spawn_session_with_deadline(
6685 spawn_workspace_id,
6686 resolved.provider.clone(),
6687 resolved.mode,
6688 resolved.terminal_size,
6689 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6690 environment_profile.clone(),
6691 bundle.clone(),
6692 context.clone(),
6693 None,
6694 None,
6695 SpawnRecordPolicy::Always,
6696 Some(deadline),
6697 &required_capabilities,
6698 harness_mcp,
6699 resolved.approval_level,
6700 resolved.network_allowlist.clone(),
6701 resolved.browser_profile_id.clone(),
6702 )
6703 .await
6704 .map(|(session, _runtime_policy)| {
6705 resolved.receipt_with_materialization(
6706 self.incarnation_id,
6707 session,
6708 environment_profile,
6709 bundle,
6710 context,
6711 )
6712 });
6713 if harness_mcp.is_none() {
6714 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6715 }
6716 result
6717 }
6718
6719 async fn spawn_from_spec_with_harness_mcp(
6720 &self,
6721 reservation_id: HarnessMcpReservationId,
6722 activation_digest: HarnessMcpActivationDigest,
6723 spec: SpawnSpec,
6724 deadline_unix_ms: u64,
6725 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6726 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6727 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6728 })?;
6729 if let Some(receipt) = registry
6730 .replay_spawn(&reservation_id, &activation_digest, &spec)
6731 .map_err(harness_mcp_failure)?
6732 {
6733 return Ok(receipt);
6734 }
6735 let resolved = self.resolve_spawn_spec(&spec)?;
6736 let prepared = registry
6737 .prepare_spawn(
6738 &reservation_id,
6739 &activation_digest,
6740 &spec,
6741 deadline_unix_ms,
6742 resolved.provider.clone(),
6743 )
6744 .map_err(harness_mcp_failure)?;
6745 self.claim_harness_mcp_spawn(
6746 &reservation_id,
6747 &activation_digest,
6748 &spec,
6749 Instant::now(),
6750 )?;
6751 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6752 let mut receipt = match spawn {
6753 Ok(receipt) => receipt,
6754 Err(error) => {
6755 let _ = registry.abort(&reservation_id, &activation_digest);
6756 return Err(error);
6757 }
6758 };
6759 let record_id = self
6760 .session_bindings
6761 .lock()
6762 .unwrap_or_else(|poisoned| poisoned.into_inner())
6763 .get(&receipt.session.session.instance_id)
6764 .and_then(|binding| binding.record_id.clone());
6765 let record_id = match record_id {
6766 Some(record_id) => record_id,
6767 None => {
6768 let rollback = self.remove_session(&receipt.session).await;
6769 let _ = registry.abort(&reservation_id, &activation_digest);
6770 if let Err(rollback_error) = rollback {
6771 return Err(rollback_error);
6772 }
6773 return Err(failure(
6774 NodeFailureCode::BindingMismatch,
6775 "harness MCP spawn did not retain its exact managed record",
6776 ));
6777 }
6778 };
6779 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6780 reservation_id: reservation_id.clone(),
6781 activation_digest: activation_digest.clone(),
6782 });
6783 if let Err(error) = registry.mark_spawned(
6784 &prepared,
6785 receipt.session.clone(),
6786 record_id,
6787 receipt.clone(),
6788 ) {
6789 let rollback = self.remove_session(&receipt.session).await;
6790 let _ = registry.abort(&reservation_id, &activation_digest);
6791 if let Err(rollback_error) = rollback {
6792 return Err(rollback_error);
6793 }
6794 return Err(harness_mcp_failure(error));
6795 }
6796 Ok(receipt)
6797 }
6798
6799 async fn spawn_managed_worktree(
6800 &self,
6801 request: ManagedWorktreeSpawnRequest,
6802 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6803 self.spawn_managed_worktree_inner(request, None).await
6804 }
6805
6806 async fn spawn_managed_worktree_inner(
6807 &self,
6808 request: ManagedWorktreeSpawnRequest,
6809 durable_v2_key: Option<&SpawnIdempotencyKey>,
6810 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6811 let accepted_at = Instant::now();
6812 if durable_v2_key.is_none() {
6813 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6814 return replayed;
6815 }
6816 }
6817 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6818 if request.spawn_spec.target.worktree_id.is_some() {
6819 let result = Err(failure(
6820 NodeFailureCode::InvalidRequest,
6821 "managed worktree spawn must not select a caller-provided worktree",
6822 ));
6823 self.remember_managed_spawn_attempt(
6824 durable_v2_key,
6825 request,
6826 result.clone(),
6827 accepted_at,
6828 );
6829 return result;
6830 }
6831 let environment_profile = self.resolve_environment_profile(&resolved)?;
6832 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6833 let context = self.resolve_context(&resolved)?;
6834 let source_workspace_id = resolved.target.workspace_id.clone();
6835 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6836 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6837 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6838 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6839 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6840 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6841 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6842 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6843 };
6844 let runtime_policy = timeout(
6845 spawn_deadline_remaining(deadline)?,
6846 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6847 ).await.map_err(|_| failure(
6848 NodeFailureCode::SpawnDeadlineExceeded,
6849 "provider admission exceeded the managed spawn deadline",
6850 ))??;
6851 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6852 return Err(failure(
6853 NodeFailureCode::UnsupportedSpawnCapability,
6854 "provider runtime does not admit a required spawn capability",
6855 ));
6856 }
6857 self.ensure_binding_capacity()?;
6858 let source_root = self.workspace_root(&source_workspace_id)?;
6859 profile.validate_for_workspace(&source_root)
6860 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6861 let base_timeout_ms = u64::try_from(
6862 spawn_deadline_remaining(deadline)?.as_millis(),
6863 ).unwrap_or(u64::MAX).max(1);
6864 let base_commit = resolve_base_commit_with_timeout(
6865 &source_root,
6866 profile.base(),
6867 base_timeout_ms,
6868 ).await.map_err(|error| {
6869 if Instant::now() >= deadline || error.message.contains("timed out") {
6870 failure(
6871 NodeFailureCode::SpawnDeadlineExceeded,
6872 "managed worktree base resolution exceeded the spawn deadline",
6873 )
6874 } else {
6875 managed_git_worktree_failure(error)
6876 }
6877 })?;
6878
6879 let lease = {
6880 let _transaction = self.state_transaction.lock()
6881 .unwrap_or_else(|poisoned| poisoned.into_inner());
6882 let previous = self.managed_worktrees.lock()
6883 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6884 let lease = self.managed_worktrees.lock()
6885 .unwrap_or_else(|poisoned| poisoned.into_inner())
6886 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6887 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6888 let previous_replay = match durable_v2_key
6889 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6890 .transpose()
6891 {
6892 Ok(previous) => previous,
6893 Err(error) => {
6894 *self.managed_worktrees.lock()
6895 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6896 return Err(error);
6897 }
6898 };
6899 if let Err(error) = self.persist_state_locked() {
6900 *self.managed_worktrees.lock()
6901 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6902 if let (Some(key), Some(previous_replay)) =
6903 (durable_v2_key, previous_replay)
6904 {
6905 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6906 }
6907 return Err(persistence_failure(error));
6908 }
6909 lease
6910 };
6911 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6912 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6913 self.managed_worktrees.lock()
6914 .unwrap_or_else(|poisoned| poisoned.into_inner())
6915 .remove_unmutated(&lease.lease_id);
6916 if self.persist_state().is_err() {
6917 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6918 }
6919 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6920 self.remember_managed_spawn_attempt(
6921 durable_v2_key,
6922 request,
6923 result.clone(),
6924 accepted_at,
6925 );
6926 return result;
6927 }
6928 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6929 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6930 Err(error) => {
6931 self.reconcile_failed_allocation(&lease, &source_root).await;
6932 let result = Err(error);
6933 self.remember_managed_spawn_attempt(
6934 durable_v2_key,
6935 request,
6936 result.clone(),
6937 accepted_at,
6938 );
6939 return result;
6940 }
6941 };
6942 let created = match create_git_worktree_with_timeout(
6943 &source_root,
6944 &lease.target_root,
6945 &lease.branch,
6946 Some(&base_commit),
6947 mutation_timeout_ms,
6948 ).await {
6949 Ok(created) => created,
6950 Err(error) => {
6951 self.reconcile_failed_allocation(&lease, &source_root).await;
6952 let result = Err(if Instant::now() >= deadline
6953 || error.message.contains("timed out")
6954 || error.message.contains("deadline")
6955 {
6956 failure(
6957 NodeFailureCode::SpawnDeadlineExceeded,
6958 "managed worktree creation exceeded the spawn deadline",
6959 )
6960 } else {
6961 managed_git_worktree_failure(error)
6962 });
6963 self.remember_managed_spawn_attempt(
6964 durable_v2_key,
6965 request,
6966 result.clone(),
6967 accepted_at,
6968 );
6969 return result;
6970 }
6971 };
6972 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6973 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6974 let result = Err(failure(
6975 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6976 "created worktree identity did not match its durable lease",
6977 ));
6978 self.remember_managed_spawn_attempt(
6979 durable_v2_key,
6980 request,
6981 result.clone(),
6982 accepted_at,
6983 );
6984 return result;
6985 }
6986 {
6987 let mut registry = self.managed_worktrees.lock()
6988 .unwrap_or_else(|poisoned| poisoned.into_inner());
6989 let current = registry.get_mut(&lease.lease_id)
6990 .expect("allocated managed worktree lease remains present");
6991 current.expected_head = Some(created.head.clone());
6992 current.state = ManagedWorktreeLeaseState::Ready;
6993 current.updated_at_unix_ms = unix_time_ms();
6994 }
6995 if let Err(error) = self.persist_state() {
6996 self.mark_managed_recovery_required(
6997 &lease.lease_id,
6998 ManagedWorktreeCleanupFailure::Backend,
6999 );
7000 let result = Err(persistence_failure(error));
7001 self.remember_managed_spawn_attempt(
7002 durable_v2_key,
7003 request,
7004 result.clone(),
7005 accepted_at,
7006 );
7007 return result;
7008 }
7009 let ready = self.managed_worktrees.lock()
7010 .unwrap_or_else(|poisoned| poisoned.into_inner())
7011 .get(&lease.lease_id)
7012 .expect("ready managed worktree lease remains present")
7013 .snapshot();
7014 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7015 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7016 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7017 let result = Err(error);
7018 self.remember_managed_spawn_attempt(
7019 durable_v2_key,
7020 request,
7021 result.clone(),
7022 accepted_at,
7023 );
7024 return result;
7025 }
7026 let spawn = self.spawn_session_with_deadline(
7027 lease.workspace_id.clone(),
7028 resolved.provider.clone(),
7029 resolved.mode,
7030 resolved.terminal_size,
7031 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7032 environment_profile.clone(),
7033 bundle.clone(),
7034 context.clone(),
7035 Some(lease.lease_id.clone()),
7036 Some(runtime_policy),
7037 SpawnRecordPolicy::Always,
7038 Some(deadline),
7039 &required_capabilities,
7040 None,
7041 resolved.approval_level,
7042 resolved.network_allowlist.clone(),
7043 resolved.browser_profile_id.clone(),
7044 ).await;
7045 let (session, effective_runtime_policy) = match spawn {
7046 Ok(spawned) => spawned,
7047 Err(error) => {
7048 if self
7049 .cleanup_managed_worktree(&lease.lease_id, true)
7050 .await
7051 .is_err()
7052 {
7053 self.mark_managed_recovery_required(
7054 &lease.lease_id,
7055 ManagedWorktreeCleanupFailure::Backend,
7056 );
7057 }
7058 let result = Err(error);
7059 self.remember_managed_spawn_attempt(
7060 durable_v2_key,
7061 request,
7062 result.clone(),
7063 accepted_at,
7064 );
7065 return result;
7066 }
7067 };
7068 let (record_id, raw_inventory_record_id) = {
7069 let mut bindings = self.session_bindings.lock()
7070 .unwrap_or_else(|poisoned| poisoned.into_inner());
7071 let binding = bindings.get_mut(&session.session.instance_id)
7072 .expect("successful spawn retains its session binding");
7073 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7074 managed_spawn_record_ownership(binding, effective_runtime_policy)
7075 };
7076 let bound = self.managed_worktrees.lock()
7077 .unwrap_or_else(|poisoned| poisoned.into_inner())
7078 .bind_session(
7079 &lease.lease_id,
7080 ManagedWorktreeSessionHolder {
7081 incarnation_id: self.incarnation_id,
7082 instance_id: session.session.instance_id,
7083 generation: session.session.generation,
7084 },
7085 record_id,
7086 unix_time_ms(),
7087 )
7088 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7089 let snapshot = match bound {
7090 Ok(snapshot) => snapshot,
7091 Err(error) => {
7092 if raw_inventory_record_id
7093 .as_ref()
7094 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7095 {
7096 self.mark_managed_recovery_required(
7097 &lease.lease_id,
7098 ManagedWorktreeCleanupFailure::Backend,
7099 );
7100 }
7101 let _ = self.remove_session(&session).await;
7102 self.mark_managed_recovery_required(
7103 &lease.lease_id,
7104 ManagedWorktreeCleanupFailure::Backend,
7105 );
7106 let result = Err(error);
7107 self.remember_managed_spawn_attempt(
7108 durable_v2_key,
7109 request,
7110 result.clone(),
7111 accepted_at,
7112 );
7113 return result;
7114 }
7115 };
7116 if let Err(error) = self.persist_state() {
7117 if raw_inventory_record_id
7118 .as_ref()
7119 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120 {
7121 self.mark_managed_recovery_required(
7122 &lease.lease_id,
7123 ManagedWorktreeCleanupFailure::Backend,
7124 );
7125 }
7126 let _ = self.remove_session(&session).await;
7127 if self
7128 .cleanup_managed_worktree(&lease.lease_id, true)
7129 .await
7130 .is_err()
7131 {
7132 self.mark_managed_recovery_required(
7133 &lease.lease_id,
7134 ManagedWorktreeCleanupFailure::Backend,
7135 );
7136 }
7137 let result = Err(persistence_failure(error));
7138 self.remember_managed_spawn_attempt(
7139 durable_v2_key,
7140 request,
7141 result.clone(),
7142 accepted_at,
7143 );
7144 return result;
7145 }
7146 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7147 let receipt = managed_spawn_receipt(
7148 &resolved,
7149 self.incarnation_id,
7150 session,
7151 snapshot,
7152 environment_profile,
7153 bundle,
7154 context,
7155 );
7156 let result = Ok(receipt);
7157 self.remember_managed_spawn_attempt(
7158 durable_v2_key,
7159 request,
7160 result.clone(),
7161 accepted_at,
7162 );
7163 result
7164 }
7165
7166 async fn spawn_managed_worktree_v2(
7167 &self,
7168 request: ManagedWorktreeSpawnRequestV2,
7169 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7170 if request.spawn_spec.target.node_id != self.node_id {
7171 return Err(failure(
7172 NodeFailureCode::SpawnTargetMismatch,
7173 "spawn target does not match this node",
7174 ));
7175 }
7176 if request.spawn_spec.target.worktree_id.is_some() {
7177 return Err(failure(
7178 NodeFailureCode::InvalidRequest,
7179 "managed worktree spawn must not select a caller-provided worktree",
7180 ));
7181 }
7182 let request_digest = managed_spawn_request_digest_v2(&request)?;
7183 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7184 match replay {
7185 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7186 DurableManagedSpawnReplayDecision::PendingLinked => {
7187 return Err(failure(
7188 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7189 "managed worktree V2 reservation has an incomplete durable allocation",
7190 ));
7191 }
7192 DurableManagedSpawnReplayDecision::Reserved => {}
7193 }
7194 let validated = (|| {
7195 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7196 let profile = self.managed_profile(
7197 &resolved.target.workspace_id,
7198 &request.worktree_profile_id,
7199 )?;
7200 if profile.revision() != &request.expected_profile_revision {
7201 return Err(failure(
7202 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7203 "managed worktree profile revision does not match the loaded profile",
7204 ));
7205 }
7206 Ok(())
7207 })();
7208 match validated {
7209 Ok(()) => {}
7210 Err(error) => {
7211 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7212 return Err(error);
7213 }
7214 }
7215 let legacy = ManagedWorktreeSpawnRequest {
7216 spawn_spec: request.spawn_spec.clone(),
7217 worktree_profile_id: request.worktree_profile_id.clone(),
7218 };
7219 match self
7220 .spawn_managed_worktree_inner(
7221 legacy,
7222 Some(&request.spawn_spec.idempotency_key),
7223 )
7224 .await
7225 {
7226 Ok(receipt) => {
7227 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7228 Ok(receipt)
7229 }
7230 Err(error) => {
7231 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7232 Err(error)
7233 }
7234 }
7235 }
7236
7237 fn managed_profile(
7238 &self,
7239 workspace_id: &WorkspaceId,
7240 profile_id: &WorktreeProfileId,
7241 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7242 if self.worktree_service_modes.get(workspace_id).copied()
7243 != Some(WorktreeServiceMode::Managed)
7244 {
7245 return Err(failure(
7246 NodeFailureCode::UnsupportedSpawnCapability,
7247 "workspace does not enable managed worktrees",
7248 ));
7249 }
7250 self.managed_worktree_profiles.get(workspace_id)
7251 .and_then(|profiles| profiles.get(profile_id))
7252 .cloned()
7253 .ok_or_else(|| failure(
7254 NodeFailureCode::InvalidRequest,
7255 "managed worktree profile is unavailable for this workspace",
7256 ))
7257 }
7258
7259 async fn reconcile_failed_allocation(
7260 &self,
7261 lease: &ManagedWorktreeLeaseRecord,
7262 source_root: &str,
7263 ) {
7264 match list_git_worktrees(source_root).await {
7265 Ok(worktrees) => {
7266 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7267 None => {
7268 match std::fs::symlink_metadata(&lease.target_root) {
7269 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7270 self.managed_worktrees.lock()
7271 .unwrap_or_else(|poisoned| poisoned.into_inner())
7272 .remove_unmutated(&lease.lease_id);
7273 let _ = self.persist_state();
7274 }
7275 _ => self.mark_managed_recovery_required(
7276 &lease.lease_id,
7277 ManagedWorktreeCleanupFailure::OwnershipConflict,
7278 ),
7279 }
7280 }
7281 Some(item) if exact_owned_worktree(
7282 lease,
7283 &item.path,
7284 item.branch.as_deref(),
7285 &item.head,
7286 ) => {
7287 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7288 }
7289 Some(_) => self.mark_managed_recovery_required(
7290 &lease.lease_id,
7291 ManagedWorktreeCleanupFailure::OwnershipConflict,
7292 ),
7293 }
7294 }
7295 Err(_) => self.mark_managed_recovery_required(
7296 &lease.lease_id,
7297 ManagedWorktreeCleanupFailure::Backend,
7298 ),
7299 }
7300 }
7301
7302 async fn reconcile_managed_worktrees(&self) {
7303 let leases = self.managed_worktrees.lock()
7304 .unwrap_or_else(|poisoned| poisoned.into_inner())
7305 .active_records()
7306 .cloned()
7307 .collect::<Vec<_>>();
7308 for lease in leases {
7309 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7310 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7311 _ => {
7312 self.mark_managed_recovery_required(
7313 &lease.lease_id,
7314 ManagedWorktreeCleanupFailure::OwnershipConflict,
7315 );
7316 continue;
7317 }
7318 };
7319 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7320 self.mark_managed_recovery_required(
7321 &lease.lease_id,
7322 ManagedWorktreeCleanupFailure::OwnershipConflict,
7323 );
7324 continue;
7325 }
7326 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7327 Ok(root) => root,
7328 Err(_) => {
7329 self.mark_managed_recovery_required(
7330 &lease.lease_id,
7331 ManagedWorktreeCleanupFailure::OwnershipConflict,
7332 );
7333 continue;
7334 }
7335 };
7336 let worktrees = match list_git_worktrees(&source_root).await {
7337 Ok(worktrees) => worktrees,
7338 Err(_) => {
7339 self.mark_managed_recovery_required(
7340 &lease.lease_id,
7341 ManagedWorktreeCleanupFailure::Backend,
7342 );
7343 continue;
7344 }
7345 };
7346 let listed = worktrees.iter()
7347 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7348 let Some(listed) = listed else {
7349 match std::fs::symlink_metadata(&lease.target_root) {
7350 Err(error) if error.kind() == io::ErrorKind::NotFound
7351 && lease.state == ManagedWorktreeLeaseState::Allocating
7352 && !lease.has_holders() => {
7353 let removed = self.managed_worktrees.lock()
7354 .unwrap_or_else(|poisoned| poisoned.into_inner())
7355 .tombstone(&lease.lease_id, unix_time_ms());
7356 if self.persist_state().is_ok() && removed.is_some() {
7357 self.publish(NodeEvent::ManagedWorktreeRemoved {
7358 lease_id: lease.lease_id.clone(),
7359 });
7360 }
7361 }
7362 _ => self.mark_managed_recovery_required(
7363 &lease.lease_id,
7364 ManagedWorktreeCleanupFailure::OwnershipConflict,
7365 ),
7366 }
7367 continue;
7368 };
7369 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7370 &lease,
7371 &listed.path,
7372 listed.branch.as_deref(),
7373 &listed.head,
7374 ) {
7375 self.mark_managed_recovery_required(
7376 &lease.lease_id,
7377 ManagedWorktreeCleanupFailure::OwnershipConflict,
7378 );
7379 continue;
7380 }
7381 if !self.workspaces.read()
7382 .unwrap_or_else(|poisoned| poisoned.into_inner())
7383 .contains_key(&lease.workspace_id)
7384 {
7385 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7386 self.mark_managed_recovery_required(
7387 &lease.lease_id,
7388 ManagedWorktreeCleanupFailure::Backend,
7389 );
7390 continue;
7391 }
7392 }
7393 let snapshot = {
7394 let mut registry = self.managed_worktrees.lock()
7395 .unwrap_or_else(|poisoned| poisoned.into_inner());
7396 let current = registry.get_mut(&lease.lease_id)
7397 .expect("reconciled managed lease remains present");
7398 current.expected_head = Some(listed.head.clone());
7399 current.cleanup_failure = None;
7400 current.state = if current.has_holders() {
7401 ManagedWorktreeLeaseState::InUse
7402 } else if current.retention == ManagedWorktreeRetention::Retain {
7403 ManagedWorktreeLeaseState::Retained
7404 } else {
7405 ManagedWorktreeLeaseState::Ready
7406 };
7407 current.updated_at_unix_ms = unix_time_ms();
7408 current.snapshot()
7409 };
7410 if self.persist_state().is_err() {
7411 self.mark_managed_recovery_required(
7412 &lease.lease_id,
7413 ManagedWorktreeCleanupFailure::Backend,
7414 );
7415 continue;
7416 }
7417 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7418 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7419 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7420 }
7421 }
7422 }
7423
7424 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7444 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7445 .session_records
7446 .lock()
7447 .unwrap_or_else(|poisoned| poisoned.into_inner())
7448 .records
7449 .values()
7450 .filter(|record| {
7451 record.state == ManagedSessionState::Live
7452 && record.exported_context.is_none()
7453 && record.provider_session.is_some()
7454 })
7455 .filter_map(|record| {
7456 record
7457 .active_session
7458 .clone()
7459 .map(|session| (record.record_id.clone(), session))
7460 })
7461 .collect();
7462 for (record_id, session) in candidates {
7463 let shared = Arc::clone(self);
7464 tokio::spawn(async move {
7465 shared
7466 .reconcile_context_pack_export_for_record(&record_id, session)
7467 .await;
7468 });
7469 }
7470 }
7471
7472 async fn reconcile_context_pack_export_for_record(
7491 &self,
7492 record_id: &SessionRecordId,
7493 session: SessionAddress,
7494 ) {
7495 let Ok(record) = self.record(record_id) else {
7496 return;
7497 };
7498 if !matches!(
7499 record.state,
7500 ManagedSessionState::Live | ManagedSessionState::Dormant
7501 ) || record.exported_context.is_some()
7502 || record.provider_session.is_none()
7503 {
7504 return;
7505 }
7506 let Ok(receipt) = self
7513 .export_context_pack_for_session_record(&record.record_id, &session, true)
7514 .await
7515 else {
7516 return;
7517 };
7518 self.set_exported_context(&record.record_id, receipt);
7519 }
7520
7521 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7526 let transaction = self
7527 .state_transaction
7528 .lock()
7529 .unwrap_or_else(|poisoned| poisoned.into_inner());
7530 let (previous, updated) = {
7531 let mut records = self
7532 .session_records
7533 .lock()
7534 .unwrap_or_else(|poisoned| poisoned.into_inner());
7535 let Some(record) = records.records.get_mut(record_id) else {
7536 return;
7537 };
7538 if record.exported_context.is_some() {
7539 return;
7540 }
7541 let previous = record.clone();
7542 record.exported_context = Some(receipt);
7543 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7544 (previous, record.clone())
7545 };
7546 if self.persist_state_locked().is_err() {
7547 self.session_records
7548 .lock()
7549 .unwrap_or_else(|poisoned| poisoned.into_inner())
7550 .records
7551 .insert(record_id.clone(), previous);
7552 return;
7553 }
7554 drop(transaction);
7555 self.publish_record(updated);
7556 }
7557
7558 fn managed_record_export_target_for_instance(
7568 &self,
7569 instance_id: AgentInstanceId,
7570 generation: SessionGeneration,
7571 ) -> Option<(SessionRecordId, SessionAddress)> {
7572 let bindings = self
7573 .session_bindings
7574 .lock()
7575 .unwrap_or_else(|poisoned| poisoned.into_inner());
7576 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7577 let record_id = binding.record_id.clone()?;
7578 Some((
7579 record_id,
7580 SessionAddress {
7581 workspace_id: binding.workspace_id.clone(),
7582 session: SessionKey { instance_id, generation },
7583 },
7584 ))
7585 }
7586
7587 fn mark_managed_recovery_required(
7588 &self,
7589 lease_id: &ManagedWorktreeLeaseId,
7590 failure_kind: ManagedWorktreeCleanupFailure,
7591 ) {
7592 let snapshot = {
7593 let mut registry = self.managed_worktrees.lock()
7594 .unwrap_or_else(|poisoned| poisoned.into_inner());
7595 let Some(lease) = registry.get_mut(lease_id) else { return };
7596 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7597 lease.cleanup_failure = Some(failure_kind);
7598 lease.updated_at_unix_ms = unix_time_ms();
7599 lease.snapshot()
7600 };
7601 let _ = self.persist_state();
7602 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7603 }
7604
7605 fn set_persistence_error(&self, error: Option<String>) {
7606 *self
7607 .persistence_error
7608 .write()
7609 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7610 .map(|message| session_registry::sanitized_persistence_summary(&message));
7611 }
7612
7613 fn persist_state(&self) -> io::Result<()> {
7614 let _transaction = self
7615 .state_transaction
7616 .lock()
7617 .unwrap_or_else(|poisoned| poisoned.into_inner());
7618 self.persist_state_locked()
7619 }
7620
7621 fn persist_state_locked(&self) -> io::Result<()> {
7622 let workspaces = self
7623 .workspaces
7624 .read()
7625 .unwrap_or_else(|poisoned| poisoned.into_inner())
7626 .clone();
7627 let records = self
7628 .session_records
7629 .lock()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner())
7631 .records
7632 .values()
7633 .cloned()
7634 .collect::<Vec<_>>();
7635 let mut managed = self.managed_worktrees
7636 .lock()
7637 .unwrap_or_else(|poisoned| poisoned.into_inner());
7638 managed.reattach_record_holders(
7639 &managed_worktree_record_holders(&records),
7640 unix_time_ms(),
7641 );
7642 let materializations = self
7643 .materializations
7644 .lock()
7645 .unwrap_or_else(|poisoned| poisoned.into_inner())
7646 .values()
7647 .cloned()
7648 .collect::<Vec<_>>();
7649 let managed_spawn_replays = self.managed_spawn_replays
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner())
7652 .values()
7653 .cloned()
7654 .collect::<Vec<_>>();
7655 let result = session_registry::save_v10(
7656 self.state_path.as_deref(),
7657 &self.node_id,
7658 &workspaces,
7659 &records,
7660 &managed.records(),
7661 &managed.tombstones(),
7662 &materializations,
7663 &managed_spawn_replays,
7664 );
7665 match &result {
7666 Ok(warning) => self.set_persistence_error(warning.clone()),
7667 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7668 }
7669 result.map(|_| ())
7670 }
7671
7672 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7673 let _mutation_guard = self.mutation_gate.lock().await;
7674 self.begin_shutdown_locked().await
7675 }
7676
7677 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7678 if self.shutdown.swap(true, Ordering::AcqRel) {
7679 return Ok(());
7680 }
7681 SessionLifecycleHook::on_shutdown(self);
7682 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7683 let mut first_error = None;
7684 for session in &self.handle.snapshot().sessions {
7685 let remaining = deadline.saturating_duration_since(Instant::now());
7686 if remaining.is_zero() {
7687 first_error.get_or_insert_with(|| {
7688 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7689 });
7690 break;
7691 }
7692 if let Err(error) = self
7693 .dispatch_bounded(
7694 ControlCommand::Stop {
7695 instance_id: session.instance_id,
7696 force: true,
7697 },
7698 remaining,
7699 )
7700 .await
7701 {
7702 first_error.get_or_insert(error);
7703 }
7704 }
7705 self.shutdown_notify.notify_waiters();
7706 match first_error {
7707 Some(error) => Err(error),
7708 None => Ok(()),
7709 }
7710 }
7711
7712 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7713 let bindings = self
7714 .session_bindings
7715 .lock()
7716 .unwrap_or_else(|poisoned| poisoned.into_inner());
7717 if bindings.len() >= CONTROL_SESSIONS_MAX {
7718 Err(failure(
7719 NodeFailureCode::BackendBusy,
7720 "node session-binding capacity is full; remove an existing session first",
7721 ))
7722 } else {
7723 Ok(())
7724 }
7725 }
7726
7727 fn ensure_workspace_environment_profile_compatible(
7734 &self,
7735 workspace_id: &WorkspaceId,
7736 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7737 ) -> Result<(), NodeFailure> {
7738 let bindings = self
7739 .session_bindings
7740 .lock()
7741 .unwrap_or_else(|poisoned| poisoned.into_inner());
7742 for binding in bindings.values() {
7743 if &binding.workspace_id != workspace_id {
7744 continue;
7745 }
7746 if binding.environment_profile.as_ref() != environment_profile {
7747 return Err(failure(
7748 NodeFailureCode::WorkspaceBusy,
7749 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7750 ));
7751 }
7752 }
7753 Ok(())
7754 }
7755
7756 #[cfg(test)]
7757 fn bind_session(
7758 &self,
7759 instance_id: AgentInstanceId,
7760 workspace_id: WorkspaceId,
7761 generation: SessionGeneration,
7762 ) {
7763 self.bind_session_with_policy(
7764 &SessionAddress {
7765 workspace_id,
7766 session: SessionKey { instance_id, generation },
7767 },
7768 ProviderRuntimePolicy::raw_pty(),
7769 None,
7770 );
7771 }
7772
7773 #[cfg(test)]
7774 fn bind_session_with_policy(
7775 &self,
7776 address: &SessionAddress,
7777 runtime_policy: ProviderRuntimePolicy,
7778 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7779 ) {
7780 self.bind_session_with_materialization(
7781 address,
7782 runtime_policy,
7783 environment_profile,
7784 None,
7785 None,
7786 None,
7787 );
7788 }
7789
7790 fn bind_session_with_materialization(
7791 &self,
7792 address: &SessionAddress,
7793 runtime_policy: ProviderRuntimePolicy,
7794 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795 bundle: Option<ResolvedBundleReceipt>,
7796 context: Option<ResolvedContextPackReceipt>,
7797 materialization_id: Option<MaterializationId>,
7798 ) {
7799 let mut bindings = self
7800 .session_bindings
7801 .lock()
7802 .unwrap_or_else(|poisoned| poisoned.into_inner());
7803 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7804 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7805 bindings.insert(address.session.instance_id, SessionBinding {
7806 workspace_id: address.workspace_id.clone(),
7807 generation: address.session.generation,
7808 runtime_policy,
7809 pending_resume: None,
7810 record_id: None,
7811 managed_worktree_lease_id: None,
7812 environment_profile,
7813 bundle,
7814 context,
7815 materialization_id,
7816 });
7817 }
7818
7819 #[cfg(test)]
7820 fn bind_managed_session(
7821 &self,
7822 address: &SessionAddress,
7823 record_id: SessionRecordId,
7824 runtime_policy: ProviderRuntimePolicy,
7825 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7826 ) {
7827 self.bind_managed_session_with_materialization(
7828 address,
7829 record_id,
7830 runtime_policy,
7831 environment_profile,
7832 None,
7833 None,
7834 None,
7835 );
7836 }
7837
7838 fn bind_managed_session_with_materialization(
7839 &self,
7840 address: &SessionAddress,
7841 record_id: SessionRecordId,
7842 runtime_policy: ProviderRuntimePolicy,
7843 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7844 bundle: Option<ResolvedBundleReceipt>,
7845 context: Option<ResolvedContextPackReceipt>,
7846 materialization_id: Option<MaterializationId>,
7847 ) {
7848 let mut bindings = self
7849 .session_bindings
7850 .lock()
7851 .unwrap_or_else(|poisoned| poisoned.into_inner());
7852 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7853 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7854 bindings.insert(
7855 address.session.instance_id,
7856 SessionBinding {
7857 workspace_id: address.workspace_id.clone(),
7858 generation: address.session.generation,
7859 runtime_policy,
7860 pending_resume: None,
7861 record_id: Some(record_id),
7862 managed_worktree_lease_id: None,
7863 environment_profile,
7864 bundle,
7865 context,
7866 materialization_id,
7867 },
7868 );
7869 }
7870
7871 #[cfg(test)]
7872 fn bind_spawn_session(
7873 &self,
7874 address: &SessionAddress,
7875 provider: AgentId,
7876 mode: SessionMode,
7877 runtime_policy: ProviderRuntimePolicy,
7878 record_policy: SpawnRecordPolicy,
7879 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7881 self.bind_spawn_session_with_materialization(
7882 address,
7883 provider,
7884 mode,
7885 runtime_policy,
7886 record_policy,
7887 environment_profile,
7888 None,
7889 None,
7890 None,
7891 )
7892 }
7893
7894 fn bind_spawn_session_with_materialization(
7895 &self,
7896 address: &SessionAddress,
7897 provider: AgentId,
7898 mode: SessionMode,
7899 runtime_policy: ProviderRuntimePolicy,
7900 record_policy: SpawnRecordPolicy,
7901 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7902 bundle: Option<ResolvedBundleReceipt>,
7903 context: Option<ResolvedContextPackReceipt>,
7904 materialization_id: Option<MaterializationId>,
7905 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7907 && !runtime_policy.provider_session_identity
7908 {
7909 self.bind_session_with_materialization(
7910 address,
7911 runtime_policy,
7912 environment_profile,
7913 bundle,
7914 context,
7915 materialization_id,
7916 );
7917 return Ok(None);
7918 }
7919 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7920 let record = self.new_record(
7921 address,
7922 provider,
7923 mode,
7924 active_record_state(runtime_policy.provider_session_identity, false),
7925 None,
7926 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7927 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7928 record_owns_lifecycle.then(|| context.clone()).flatten(),
7929 )?;
7930 let record_id = record.record_id.clone();
7931 let transaction = self
7932 .state_transaction
7933 .lock()
7934 .unwrap_or_else(|poisoned| poisoned.into_inner());
7935 self.insert_record(record.clone())?;
7936 let previous_materialization = if record_owns_lifecycle {
7937 if let Some(materialization_id) = materialization_id.as_ref() {
7938 let mut materializations = self
7939 .materializations
7940 .lock()
7941 .unwrap_or_else(|poisoned| poisoned.into_inner());
7942 let Some(ownership) = materializations.get_mut(materialization_id) else {
7943 drop(materializations);
7944 self.remove_record_memory(&record_id);
7945 return Err(failure(
7946 NodeFailureCode::BackendOperationFailed,
7947 "session environment ownership disappeared before binding",
7948 ));
7949 };
7950 let previous = ownership.clone();
7951 if ownership
7952 .transfer_to_record(record_id.clone(), unix_time_ms())
7953 .is_err()
7954 {
7955 drop(materializations);
7956 self.remove_record_memory(&record_id);
7957 return Err(failure(
7958 NodeFailureCode::BackendOperationFailed,
7959 "session environment ownership transfer failed",
7960 ));
7961 }
7962 Some(previous)
7963 } else {
7964 None
7965 }
7966 } else {
7967 None
7968 };
7969 self.bind_managed_session_with_materialization(
7970 address,
7971 record_id.clone(),
7972 runtime_policy,
7973 environment_profile,
7974 bundle,
7975 context,
7976 materialization_id.clone(),
7977 );
7978 if let Err(error) = self.persist_state_locked() {
7979 self.remove_binding(address);
7980 self.remove_record_memory(&record_id);
7981 if let (Some(materialization_id), Some(previous)) =
7982 (materialization_id.as_ref(), previous_materialization)
7983 {
7984 self.materializations
7985 .lock()
7986 .unwrap_or_else(|poisoned| poisoned.into_inner())
7987 .insert(materialization_id.clone(), previous);
7988 }
7989 return Err(persistence_failure(error));
7990 }
7991 drop(transaction);
7992 self.publish_record(record);
7993 Ok(Some(record_id))
7994 }
7995
7996 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7997 for _ in 0..8 {
7998 let bytes = random_nonce().map_err(|error| {
7999 failure(
8000 NodeFailureCode::BackendOperationFailed,
8001 &format!("session record identity generation failed: {error}"),
8002 )
8003 })?;
8004 let mut value = String::with_capacity(27);
8005 value.push_str("sr-");
8006 for byte in &bytes[..12] {
8007 use std::fmt::Write as _;
8008 let _ = write!(&mut value, "{byte:02x}");
8009 }
8010 let record_id = SessionRecordId::new(value).map_err(|error| {
8011 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8012 })?;
8013 let records = self
8014 .session_records
8015 .lock()
8016 .unwrap_or_else(|poisoned| poisoned.into_inner());
8017 if !records.records.contains_key(&record_id) {
8018 return Ok(record_id);
8019 }
8020 }
8021 Err(failure(
8022 NodeFailureCode::BackendBusy,
8023 "could not allocate a unique session record ID",
8024 ))
8025 }
8026
8027 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8028 for _ in 0..8 {
8029 let bytes = random_nonce().map_err(|error| {
8030 failure(
8031 NodeFailureCode::BackendOperationFailed,
8032 &format!("task identity generation failed: {error}"),
8033 )
8034 })?;
8035 let mut nonce = [0_u8; 12];
8036 nonce.copy_from_slice(&bytes[..12]);
8037 let task_id = TaskId::from_nonce(nonce);
8038 let records = self
8039 .session_records
8040 .lock()
8041 .unwrap_or_else(|poisoned| poisoned.into_inner());
8042 if !records.records.values().any(|record| {
8043 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8044 == Some(&task_id)
8045 }) {
8046 return Ok(task_id);
8047 }
8048 }
8049 Err(failure(
8050 NodeFailureCode::BackendBusy,
8051 "could not allocate a unique task ID",
8052 ))
8053 }
8054
8055 fn default_record_name(&self, provider: &AgentId) -> String {
8056 let records = self
8057 .session_records
8058 .lock()
8059 .unwrap_or_else(|poisoned| poisoned.into_inner());
8060 let ordinal = records
8061 .records
8062 .values()
8063 .filter(|record| &record.provider == provider)
8064 .count()
8065 .saturating_add(1);
8066 format!("{} #{ordinal}", provider.as_str())
8067 }
8068
8069 fn new_record(
8070 &self,
8071 address: &SessionAddress,
8072 provider: AgentId,
8073 mode: SessionMode,
8074 state: ManagedSessionState,
8075 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8076 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8077 bundle: Option<ResolvedBundleReceipt>,
8078 context: Option<ResolvedContextPackReceipt>,
8079 ) -> Result<ManagedSessionRecord, NodeFailure> {
8080 let canonical_root = self.workspace_root(&address.workspace_id)?;
8081 let now = unix_time_ms();
8082 Ok(ManagedSessionRecord {
8083 record_id: self.allocate_record_id()?,
8084 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8085 provider,
8086 mode,
8087 state,
8088 workspace_id: address.workspace_id.clone(),
8089 canonical_root: opaque_windows_path(canonical_root),
8090 provider_session,
8091 active_session: Some(address.clone()),
8092 environment_profile,
8093 bundle,
8094 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8095 context,
8096 exported_context: None,
8097 task_binding: None,
8098 created_at_unix_ms: now,
8099 updated_at_unix_ms: now,
8100 last_error: None,
8101 })
8102 }
8103
8104 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8105 let mut records = self
8106 .session_records
8107 .lock()
8108 .unwrap_or_else(|poisoned| poisoned.into_inner());
8109 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8110 return Err(failure(
8111 NodeFailureCode::BackendBusy,
8112 "managed session record capacity is full; forget an inactive record first",
8113 ));
8114 }
8115 if let Some(identity) = record.provider_session.as_ref() {
8116 if records.records.values().any(|candidate| {
8117 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8118 session_registry::same_provider_session(
8119 &candidate.provider,
8120 candidate_identity,
8121 &record.provider,
8122 identity,
8123 )
8124 })
8125 }) {
8126 return Err(failure(
8127 NodeFailureCode::SessionRecordConflict,
8128 "provider session is already represented by another managed record",
8129 ));
8130 }
8131 }
8132 match records.records.entry(record.record_id.clone()) {
8133 std::collections::btree_map::Entry::Vacant(entry) => {
8134 entry.insert(record);
8135 }
8136 std::collections::btree_map::Entry::Occupied(_) => {
8137 return Err(failure(
8138 NodeFailureCode::SessionRecordConflict,
8139 "session record ID collision",
8140 ));
8141 }
8142 }
8143 Ok(())
8144 }
8145
8146 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8147 self.session_records
8148 .lock()
8149 .unwrap_or_else(|poisoned| poisoned.into_inner())
8150 .records
8151 .get(record_id)
8152 .cloned()
8153 .ok_or_else(|| {
8154 failure(
8155 NodeFailureCode::UnknownSessionRecord,
8156 "managed session record does not exist",
8157 )
8158 })
8159 }
8160
8161 fn revalidate_session_record_preview(
8162 &self,
8163 expected: &ManagedSessionRecord,
8164 identity: &ProviderSessionIdentity,
8165 ) -> Result<(), NodeFailure> {
8166 let current = self.record(&expected.record_id)?;
8167 let current_root = self.workspace_root(¤t.workspace_id)?;
8168 if current.provider != expected.provider
8169 || current.workspace_id != expected.workspace_id
8170 || current.canonical_root != expected.canonical_root
8171 || current.provider_session.as_ref() != Some(identity)
8172 || !platform::roots_equal(
8173 ¤t_root,
8174 windows_path_text(¤t.canonical_root),
8175 )
8176 {
8177 return Err(failure(
8178 NodeFailureCode::SessionRecordConflict,
8179 "managed session changed while its preview was loading",
8180 ));
8181 }
8182 Ok(())
8183 }
8184
8185 fn revalidate_session_record_context_export(
8186 &self,
8187 expected: &ManagedSessionRecord,
8188 identity: Option<&ProviderSessionIdentity>,
8189 session: &SessionAddress,
8190 allow_clean_detachment: bool,
8191 ) -> Result<(), NodeFailure> {
8192 let current = self.record(&expected.record_id)?;
8193 let current_root = self.workspace_root(¤t.workspace_id)?;
8194 let snapshot = self.internal_session_snapshot(session)?;
8195 let binding = self
8196 .session_bindings
8197 .lock()
8198 .unwrap_or_else(|poisoned| poisoned.into_inner())
8199 .get(&session.session.instance_id)
8200 .cloned()
8201 .ok_or_else(|| {
8202 failure(
8203 NodeFailureCode::SessionRecordConflict,
8204 "managed session runtime binding disappeared during context export",
8205 )
8206 })?;
8207 if !session_record_context_export_binding_is_exact(
8208 expected,
8209 ¤t,
8210 identity,
8211 session,
8212 &binding,
8213 &snapshot.agent_id,
8214 ¤t_root,
8215 allow_clean_detachment,
8216 ) || !session_record_context_export_source_is_usable(
8217 ¤t,
8218 &snapshot.status,
8219 allow_clean_detachment,
8220 ) {
8221 return Err(failure(
8222 NodeFailureCode::SessionRecordConflict,
8223 "managed session binding changed during context export",
8224 ));
8225 }
8226 Ok(())
8227 }
8228
8229 fn revalidate_loaded_history(
8230 &self,
8231 session: &SessionAddress,
8232 candidate_id: &str,
8233 loaded: &HistorySessionRecord,
8234 ) -> Result<(), NodeFailure> {
8235 let snapshot = self.internal_session_snapshot(session)?;
8236 if snapshot.history.pending.is_some()
8237 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8238 || snapshot.history.loaded.as_ref() != Some(loaded)
8239 {
8240 return Err(failure(
8241 NodeFailureCode::SessionRecordConflict,
8242 "managed session history changed during context export",
8243 ));
8244 }
8245 Ok(())
8246 }
8247
8248 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8249 self.session_records
8250 .lock()
8251 .unwrap_or_else(|poisoned| poisoned.into_inner())
8252 .records
8253 .remove(record_id)
8254 }
8255
8256 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8257 if self.destroy_record_materialization(record_id)? {
8258 self.publish(NodeEvent::SessionRecordRemoved {
8259 record_id: record_id.clone(),
8260 });
8261 return Ok(());
8262 }
8263 let transaction = self
8264 .state_transaction
8265 .lock()
8266 .unwrap_or_else(|poisoned| poisoned.into_inner());
8267 let Some(removed) = self.remove_record_memory(record_id) else {
8268 return Ok(());
8269 };
8270 if let Err(error) = self.persist_state_locked() {
8271 let _ = self.insert_record(removed);
8272 return Err(persistence_failure(error));
8273 }
8274 drop(transaction);
8275 self.publish(NodeEvent::SessionRecordRemoved {
8276 record_id: record_id.clone(),
8277 });
8278 Ok(())
8279 }
8280
8281 fn publish_record(&self, record: ManagedSessionRecord) {
8282 self.publish(NodeEvent::SessionRecordUpserted { record });
8283 }
8284
8285 fn mark_record_error(
8286 &self,
8287 record_id: &SessionRecordId,
8288 message: &str,
8289 ) -> Result<(), NodeFailure> {
8290 let transaction = self
8291 .state_transaction
8292 .lock()
8293 .unwrap_or_else(|poisoned| poisoned.into_inner());
8294 let previous = {
8295 let mut records = self
8296 .session_records
8297 .lock()
8298 .unwrap_or_else(|poisoned| poisoned.into_inner());
8299 let Some(record) = records.records.get_mut(record_id) else {
8300 return Ok(());
8301 };
8302 let previous = record.clone();
8303 record.state = ManagedSessionState::Unavailable;
8304 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8305 record.updated_at_unix_ms = unix_time_ms();
8306 previous
8307 };
8308 if let Err(error) = self.persist_state_locked() {
8309 self.session_records
8310 .lock()
8311 .unwrap_or_else(|poisoned| poisoned.into_inner())
8312 .records
8313 .insert(record_id.clone(), previous);
8314 return Err(persistence_failure(error));
8315 }
8316 let record = self.record(record_id)?;
8317 drop(transaction);
8318 self.publish_record(record);
8319 Ok(())
8320 }
8321
8322 fn rename_session_record(
8323 &self,
8324 record_id: &SessionRecordId,
8325 display_name: String,
8326 ) -> Result<ManagedSessionRecord, NodeFailure> {
8327 validate_display_name(&display_name)
8328 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8329 let _transaction = self
8330 .state_transaction
8331 .lock()
8332 .unwrap_or_else(|poisoned| poisoned.into_inner());
8333 let previous = {
8334 let mut records = self
8335 .session_records
8336 .lock()
8337 .unwrap_or_else(|poisoned| poisoned.into_inner());
8338 let record = records.records.get_mut(record_id).ok_or_else(|| {
8339 failure(
8340 NodeFailureCode::UnknownSessionRecord,
8341 "managed session record does not exist",
8342 )
8343 })?;
8344 let previous = record.clone();
8345 record.display_name = display_name;
8346 record.updated_at_unix_ms = unix_time_ms();
8347 previous
8348 };
8349 if let Err(error) = self.persist_state_locked() {
8350 self.session_records
8351 .lock()
8352 .unwrap_or_else(|poisoned| poisoned.into_inner())
8353 .records
8354 .insert(record_id.clone(), previous);
8355 return Err(persistence_failure(error));
8356 }
8357 let updated = self.record(record_id)?;
8358 self.publish_record(updated.clone());
8359 Ok(updated)
8360 }
8361
8362 fn set_session_task(
8363 &self,
8364 record_id: &SessionRecordId,
8365 expected_revision: u64,
8366 target: SessionTaskTargetV1,
8367 ) -> Result<ManagedSessionRecord, NodeFailure> {
8368 let transaction = self
8369 .state_transaction
8370 .lock()
8371 .unwrap_or_else(|poisoned| poisoned.into_inner());
8372 let current = self.record(record_id)?;
8373 let current_task_id = current
8374 .task_binding
8375 .as_ref()
8376 .and_then(|binding| binding.task_id.as_ref());
8377 let current_revision = current
8378 .task_binding
8379 .as_ref()
8380 .map_or(0, |binding| binding.revision);
8381 if expected_revision != current_revision {
8382 return Err(failure(
8383 NodeFailureCode::SessionRecordConflict,
8384 "managed session task binding revision changed",
8385 ));
8386 }
8387 let exact = match &target {
8388 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8389 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8390 SessionTaskTargetV1::New => false,
8391 };
8392 if exact {
8393 return Ok(current);
8394 }
8395 let revision = current_revision.checked_add(1).ok_or_else(|| {
8396 failure(
8397 NodeFailureCode::SessionRecordConflict,
8398 "managed session task binding revision is exhausted",
8399 )
8400 })?;
8401 let task_id = match target {
8402 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8403 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8404 SessionTaskTargetV1::Clear => None,
8405 };
8406 let changed_at_unix_ms = unix_time_ms()
8407 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8408 failure(
8409 NodeFailureCode::SessionRecordConflict,
8410 "managed session record timestamp is exhausted",
8411 )
8412 })?);
8413 let updated = {
8414 let mut records = self
8415 .session_records
8416 .lock()
8417 .unwrap_or_else(|poisoned| poisoned.into_inner());
8418 let record = records.records.get_mut(record_id).ok_or_else(|| {
8419 failure(
8420 NodeFailureCode::UnknownSessionRecord,
8421 "managed session record does not exist",
8422 )
8423 })?;
8424 record.task_binding = Some(SessionTaskBindingV1 {
8425 revision,
8426 task_id,
8427 changed_at_unix_ms,
8428 });
8429 record.updated_at_unix_ms = changed_at_unix_ms;
8430 record.clone()
8431 };
8432 if let Err(error) = self.persist_state_locked() {
8433 self.session_records
8434 .lock()
8435 .unwrap_or_else(|poisoned| poisoned.into_inner())
8436 .records
8437 .insert(record_id.clone(), current);
8438 return Err(persistence_failure(error));
8439 }
8440 drop(transaction);
8441 self.publish_record(updated.clone());
8442 Ok(updated)
8443 }
8444
8445 fn index_provider_session(
8446 &self,
8447 workspace_id: WorkspaceId,
8448 provider: AgentId,
8449 identity: ProviderSessionIdentity,
8450 display_name: String,
8451 ) -> Result<ManagedSessionRecord, NodeFailure> {
8452 self.index_provider_session_with_policy(
8453 workspace_id,
8454 provider,
8455 identity,
8456 display_name,
8457 false,
8458 )
8459 }
8460
8461 fn index_provider_session_with_policy(
8462 &self,
8463 workspace_id: WorkspaceId,
8464 provider: AgentId,
8465 identity: ProviderSessionIdentity,
8466 display_name: String,
8467 allow_validated_transcript_path: bool,
8468 ) -> Result<ManagedSessionRecord, NodeFailure> {
8469 validate_display_name(&display_name)
8470 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8471 identity
8472 .validate()
8473 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8474 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8475 return Err(failure(
8476 NodeFailureCode::InvalidRequest,
8477 "provider session reference index v1 does not accept transcript paths",
8478 ));
8479 }
8480 let canonical_root = self.workspace_root(&workspace_id)?;
8481 if !self.enabled_providers.contains(&provider) {
8482 return Err(failure(
8483 NodeFailureCode::InvalidRequest,
8484 "provider is not enabled on this node",
8485 ));
8486 }
8487
8488 let transaction = self
8489 .state_transaction
8490 .lock()
8491 .unwrap_or_else(|poisoned| poisoned.into_inner());
8492 {
8493 let records = self
8494 .session_records
8495 .lock()
8496 .unwrap_or_else(|poisoned| poisoned.into_inner());
8497 if let Some(existing) = records.records.values().find(|candidate| {
8498 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8499 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8500 })
8501 }) {
8502 if existing.provider == provider && existing.workspace_id == workspace_id {
8503 return Ok(existing.clone());
8504 }
8505 return Err(failure(
8506 NodeFailureCode::SessionRecordConflict,
8507 "provider session reference conflicts with another provider or workspace",
8508 ));
8509 }
8510 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8511 return Err(failure(
8512 NodeFailureCode::BackendBusy,
8513 "managed session record capacity is full; forget an inactive record first",
8514 ));
8515 }
8516 }
8517
8518 let now = unix_time_ms();
8519 let record = ManagedSessionRecord {
8520 record_id: self.allocate_record_id()?,
8521 display_name,
8522 provider,
8523 mode: SessionMode::Pty,
8524 state: ManagedSessionState::Dormant,
8525 workspace_id,
8526 canonical_root: opaque_windows_path(canonical_root),
8527 provider_session: Some(identity),
8528 active_session: None,
8529 environment_profile: None,
8530 bundle: None,
8531 context_id: None,
8532 context: None,
8533 exported_context: None,
8534 task_binding: None,
8535 created_at_unix_ms: now,
8536 updated_at_unix_ms: now,
8537 last_error: None,
8538 };
8539 self.insert_record(record.clone())?;
8540 if let Err(error) = self.persist_state_locked() {
8541 self.remove_record_memory(&record.record_id);
8542 return Err(persistence_failure(error));
8543 }
8544 drop(transaction);
8545 self.publish_record(record.clone());
8546 Ok(record)
8547 }
8548
8549 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8550 self.session_bindings
8551 .lock()
8552 .unwrap_or_else(|poisoned| poisoned.into_inner())
8553 .iter()
8554 .find_map(|(instance_id, binding)| {
8555 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8556 workspace_id: binding.workspace_id.clone(),
8557 session: SessionKey {
8558 instance_id: *instance_id,
8559 generation: binding.generation,
8560 },
8561 })
8562 })
8563 }
8564
8565 async fn forget_session_record(
8566 &self,
8567 record_id: &SessionRecordId,
8568 ) -> Result<(), NodeFailure> {
8569 let record = self.record(record_id)?;
8570 if record.active_session.is_some()
8571 || matches!(
8572 record.state,
8573 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8574 )
8575 {
8576 return Err(failure(
8577 NodeFailureCode::SessionRecordBusy,
8578 "stop the managed session before forgetting it",
8579 ));
8580 }
8581 if let Some(address) = self.bound_address_for_record(record_id) {
8582 self.remove_session(&address).await?;
8583 }
8584 self.remove_managed_session_record(record_id, &record.workspace_id).await
8585 }
8586
8587 async fn remove_managed_session_record(
8602 &self,
8603 record_id: &SessionRecordId,
8604 workspace_id: &WorkspaceId,
8605 ) -> Result<(), NodeFailure> {
8606 let managed_lease_id = self.managed_worktrees.lock()
8607 .unwrap_or_else(|poisoned| poisoned.into_inner())
8608 .lease_for_workspace(workspace_id);
8609 if !self.destroy_record_materialization(record_id)? {
8610 let transaction = self
8611 .state_transaction
8612 .lock()
8613 .unwrap_or_else(|poisoned| poisoned.into_inner());
8614 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8615 failure(
8616 NodeFailureCode::UnknownSessionRecord,
8617 "managed session record does not exist",
8618 )
8619 })?;
8620 if let Err(error) = self.persist_state_locked() {
8621 let _ = self.insert_record(removed);
8622 return Err(persistence_failure(error));
8623 }
8624 drop(transaction);
8625 }
8626 self.publish(NodeEvent::SessionRecordRemoved {
8627 record_id: record_id.clone(),
8628 });
8629 if let Some(lease_id) = managed_lease_id {
8630 if let Some(snapshot) = self.managed_worktrees.lock()
8631 .unwrap_or_else(|poisoned| poisoned.into_inner())
8632 .get(&lease_id)
8633 .map(ManagedWorktreeLeaseRecord::snapshot)
8634 {
8635 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8636 }
8637 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8638 }
8639 Ok(())
8640 }
8641
8642 async fn retire_unavailable_session_records(&self) {
8655 let policy = self.session_record_retention;
8656 let age_enabled = policy.age_ms > 0;
8657 let keep_enabled = policy.keep_per_workspace > 0;
8658 if !age_enabled && !keep_enabled {
8659 return;
8660 }
8661 let now = unix_time_ms();
8662 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8663 self.session_records
8664 .lock()
8665 .unwrap_or_else(|poisoned| poisoned.into_inner())
8666 .records
8667 .values()
8668 .filter(|record| record.state == ManagedSessionState::Unavailable)
8669 .map(|record| {
8670 (
8671 record.record_id.clone(),
8672 record.workspace_id.clone(),
8673 record.provider.clone(),
8674 record.updated_at_unix_ms,
8675 )
8676 })
8677 .collect()
8678 };
8679 if candidates.is_empty() {
8680 return;
8681 }
8682 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8683 BTreeMap::new();
8684 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8685 by_workspace
8686 .entry(workspace_id)
8687 .or_default()
8688 .push((record_id, provider, updated_at_unix_ms));
8689 }
8690 let reason = if age_enabled && keep_enabled {
8691 "age+keep-n"
8692 } else if age_enabled {
8693 "age"
8694 } else {
8695 "keep-n"
8696 };
8697 let mut retired: u64 = 0;
8698 for (workspace_id, mut records) in by_workspace {
8699 records.sort_by(|left, right| right.2.cmp(&left.2));
8702 for (index, (record_id, provider, updated_at_unix_ms)) in
8703 records.into_iter().enumerate()
8704 {
8705 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8706 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8707 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8708 let should_retire = if age_enabled && keep_enabled {
8709 age_hit && keep_hit
8710 } else {
8711 age_hit || keep_hit
8712 };
8713 if !should_retire {
8714 continue;
8715 }
8716 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8717 Ok(()) => {
8718 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8719 retired += 1;
8720 tracing::info!(
8721 node_id = %self.node_id,
8722 record_id = %record_id,
8723 workspace_id = %workspace_id,
8724 provider = %provider,
8725 age_ms = record_age_ms,
8726 reason,
8727 "managed session record retired"
8728 );
8729 }
8730 Err(error) => tracing::warn!(
8731 node_id = %self.node_id,
8732 record_id = %record_id,
8733 workspace_id = %workspace_id,
8734 code = ?error.code,
8735 cause = %error.message,
8736 "session record retention sweep failed to retire a record"
8737 ),
8738 }
8739 }
8740 }
8741 if retired > 0 {
8742 let remaining_unavailable = self
8743 .session_records
8744 .lock()
8745 .unwrap_or_else(|poisoned| poisoned.into_inner())
8746 .records
8747 .values()
8748 .filter(|record| record.state == ManagedSessionState::Unavailable)
8749 .count();
8750 tracing::info!(
8751 node_id = %self.node_id,
8752 retired,
8753 remaining_unavailable,
8754 "session record retention sweep completed"
8755 );
8756 }
8757 }
8758
8759 async fn resume_session_record(
8760 &self,
8761 record_id: &SessionRecordId,
8762 terminal_size: gate4agent_types::TerminalSize,
8763 initial_prompt: Option<String>,
8764 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8765 let mut record = self.record(record_id)?;
8766 if record.mode == SessionMode::Acp
8769 && record.provider.as_str() == "claude"
8770 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8771 {
8772 record.mode = SessionMode::Pty;
8773 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8774 r.mode = SessionMode::Pty;
8775 }
8776 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8777 }
8778 if record.active_session.is_some()
8779 || matches!(
8780 record.state,
8781 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8782 )
8783 {
8784 return Err(failure(
8785 NodeFailureCode::SessionRecordBusy,
8786 "managed session already has a live runtime binding",
8787 ));
8788 }
8789 let identity = record.provider_session.clone().ok_or_else(|| {
8790 failure(
8791 NodeFailureCode::SessionRecordNotResumable,
8792 "managed session has no verified provider session identity",
8793 )
8794 })?;
8795 let working_directory = self.workspace_root(&record.workspace_id)?;
8796 if !platform::roots_equal(
8797 &working_directory,
8798 windows_path_text(&record.canonical_root),
8799 ) {
8800 return Err(failure(
8801 NodeFailureCode::SessionRecordConflict,
8802 "managed session workspace root changed; refusing to resume in another directory",
8803 ));
8804 }
8805 {
8806 let records = self
8807 .session_records
8808 .lock()
8809 .unwrap_or_else(|poisoned| poisoned.into_inner());
8810 if records.records.values().any(|candidate| {
8811 candidate.record_id != *record_id
8812 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8813 session_registry::same_provider_session(
8814 &candidate.provider,
8815 candidate_identity,
8816 &record.provider,
8817 &identity,
8818 )
8819 })
8820 && (candidate.active_session.is_some()
8821 || matches!(
8822 candidate.state,
8823 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8824 ))
8825 }) {
8826 return Err(failure(
8827 NodeFailureCode::SessionRecordConflict,
8828 "provider session is already active through another managed record",
8829 ));
8830 }
8831 }
8832 let runtime_requirement = if initial_prompt.is_some() {
8833 ProviderRuntimeRequirement::ResumeWithPrompt
8834 } else {
8835 ProviderRuntimeRequirement::Resume
8836 };
8837 let runtime_policy = self.admit_provider_runtime(
8838 &record.provider,
8839 runtime_requirement,
8840 )
8841 .await?;
8842 let working_directory = match self.m4a_accounts.as_ref() {
8844 Some(_) => {
8845 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8846 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8847 }
8848 None => working_directory,
8849 };
8850 let request = ResumeLaunchRequest {
8851 working_directory,
8852 terminal_size,
8853 initial_prompt,
8854 };
8855 request
8856 .validate()
8857 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8858 self.ensure_binding_capacity()?;
8859 let resolved_materialization = match self.resolve_record_materialization(
8860 record_id,
8861 &record.provider,
8862 record.mode,
8863 record.environment_profile.as_ref(),
8864 record.bundle.as_ref(),
8865 record.context.as_ref(),
8866 ) {
8867 Ok(materialization) => materialization,
8868 Err(error) => {
8869 let label = match error.code {
8870 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8871 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8872 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8873 _ => "environment-profile-unavailable",
8874 };
8875 self.mark_record_error(record_id, label)?;
8876 return Err(error);
8877 }
8878 };
8879 let (materialization_id, environment_overlay) = match resolved_materialization {
8880 Some((id, overlay)) => (Some(id), overlay),
8881 None => (None, None),
8882 };
8883 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8884 let environment_overlay = {
8886 let mut args: Vec<OsString> = Vec::new();
8887 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8888 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8889 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8890 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8891 }
8892 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8893 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8894 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8895 if let Some(sel) = options.as_ref() {
8896 args.push("--model".into()); args.push(sel.model.clone().into());
8897 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8898 args.push("--effort".into()); args.push(effort.to_owned().into());
8899 }
8900 }
8901 }
8902 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8903 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8904 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8905 instance_id: instance_id.0,
8906 provider: record.provider.as_str().to_owned(),
8907 workspace_id: record.workspace_id.as_str().to_owned(),
8908 provider_session_id: identity.id.clone(),
8909 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8910 cwd: String::new(),
8911 acp: record.mode == SessionMode::Acp,
8912 options,
8913 cols: 0,
8914 rows: 0,
8915 });
8916 }
8917 }
8918 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8919 };
8920 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8922 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8923 if let Some(old) = self.bound_address_for_record(record_id) {
8924 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8925 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8926 }
8927 }
8928 let session_key = self.m4a_session_key(instance_id);
8929 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8930 }
8931 _ => environment_overlay,
8932 };
8933 let mut environment_selection = match SpawnPreparer::select_environment(self,
8934 instance_id,
8935 &record.provider,
8936 record.mode,
8937 record.environment_profile.as_ref(),
8938 ) {
8939 Ok(selection) => selection,
8940 Err(error) => {
8941 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8942 return Err(error);
8943 }
8944 };
8945 let mut instance_overlay = environment_overlay
8946 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8947 .transpose()?
8948 .flatten();
8949 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8950 self.remove_session(&stale_address).await?;
8951 }
8952 let address = SessionAddress {
8953 workspace_id: record.workspace_id.clone(),
8954 session: SessionKey {
8955 instance_id,
8956 generation: SessionGeneration(1),
8957 },
8958 };
8959 let transaction = self
8960 .state_transaction
8961 .lock()
8962 .unwrap_or_else(|poisoned| poisoned.into_inner());
8963 let current = self.record(record_id)?;
8964 if current.active_session.is_some()
8965 || matches!(
8966 current.state,
8967 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8968 )
8969 {
8970 return Err(failure(
8971 NodeFailureCode::SessionRecordBusy,
8972 "managed session became active while resume was being prepared",
8973 ));
8974 }
8975 self.bind_managed_session_with_materialization(
8976 &address,
8977 record_id.clone(),
8978 runtime_policy,
8979 record.environment_profile.clone(),
8980 record.bundle.clone(),
8981 record.context.clone(),
8982 materialization_id,
8983 );
8984 if let Some(selection) = environment_selection.take() {
8985 selection.retain();
8986 }
8987 if let Some(overlay) = instance_overlay.take() {
8988 overlay.retain();
8989 }
8990 drop(transaction);
8991
8992 let transport = match record.mode {
8993 SessionMode::Pty => TransportKind::Pty,
8994 SessionMode::Inline => TransportKind::Pipe,
8995 SessionMode::Acp => TransportKind::Acp,
8996 };
8997 let agent_id = record.provider.clone();
8998 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8999 if let Err(error) = self
9000 .dispatch_bounded(
9001 ControlCommand::Register {
9002 instance_id,
9003 agent_id,
9004 transport,
9005 },
9006 dispatch_timeout,
9007 )
9008 .await
9009 {
9010 if let Some(binding) = self.remove_binding(&address) {
9011 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9012 }
9013 return Err(error);
9014 }
9015 let command = self.prepare_command(ControlCommand::Resume {
9016 instance_id,
9017 target: ResumeTarget::ProviderSession { identity },
9018 runtime_policy,
9019 request,
9020 });
9021 let started_after = self.current_sequence();
9022 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9023 self.rollback_spawn(&address, dispatch_timeout).await?;
9024 return Err(error);
9025 }
9026 if let Err(error) = self.dispatch_envelope(command) {
9027 self.clear_armed_resume(&address);
9028 self.rollback_spawn(&address, dispatch_timeout).await?;
9029 return Err(error);
9030 }
9031 match self
9032 .wait_for_record_resume(&address, started_after)
9033 .await
9034 {
9035 Ok(settled_address) => {
9036 let managed_lease_id = {
9037 self.managed_worktrees.lock()
9038 .unwrap_or_else(|poisoned| poisoned.into_inner())
9039 .lease_for_workspace(&record.workspace_id)
9040 };
9041 if let Some(lease_id) = managed_lease_id {
9042 {
9043 let mut bindings = self.session_bindings.lock()
9044 .unwrap_or_else(|poisoned| poisoned.into_inner());
9045 let binding = bindings.get_mut(&settled_address.session.instance_id)
9046 .ok_or_else(|| failure(
9047 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9048 "resumed managed session lost its exact binding",
9049 ))?;
9050 if binding.generation != settled_address.session.generation {
9051 return Err(failure(
9052 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9053 "resumed managed session generation diverged",
9054 ));
9055 }
9056 binding.managed_worktree_lease_id = Some(lease_id.clone());
9057 }
9058 let snapshot = self.managed_worktrees.lock()
9059 .unwrap_or_else(|poisoned| poisoned.into_inner())
9060 .bind_session(
9061 &lease_id,
9062 ManagedWorktreeSessionHolder {
9063 incarnation_id: self.incarnation_id,
9064 instance_id: settled_address.session.instance_id,
9065 generation: settled_address.session.generation,
9066 },
9067 Some(record_id.clone()),
9068 unix_time_ms(),
9069 ).map_err(|message| failure(
9070 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071 &message,
9072 ))?;
9073 if let Err(error) = self.persist_state() {
9074 let _ = self.remove_session(&settled_address).await;
9075 return Err(persistence_failure(error));
9076 }
9077 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9078 }
9079 Ok((self.record(record_id)?, settled_address))
9080 }
9081 Err(error) => {
9082 if matches!(
9083 error.code,
9084 NodeFailureCode::SessionRecordNotResumable
9085 | NodeFailureCode::BackendOperationFailed
9086 ) {
9087 if let Some(bound) = self.bound_address_for_record(record_id) {
9088 let _ = self.remove_session(&bound).await;
9089 }
9090 }
9091 Err(error)
9092 }
9093 }
9094 }
9095
9096 async fn wait_for_record_resume(
9097 &self,
9098 initial_address: &SessionAddress,
9099 after_sequence: u64,
9100 ) -> Result<SessionAddress, NodeFailure> {
9101 let deadline = Instant::now()
9102 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9103 let mut scan_after = after_sequence;
9104 loop {
9105 let events = self
9106 .history
9107 .lock()
9108 .unwrap_or_else(|poisoned| poisoned.into_inner())
9109 .events
9110 .iter()
9111 .filter(|event| event.sequence > scan_after)
9112 .cloned()
9113 .collect::<Vec<_>>();
9114 for envelope in events {
9115 scan_after = scan_after.max(envelope.sequence);
9116 let NodeEvent::Control { address, event } = envelope.event else {
9117 continue;
9118 };
9119 if address.session.instance_id != initial_address.session.instance_id {
9120 continue;
9121 }
9122 match event.event {
9123 ControlEventKind::Resumed { .. } => return Ok(address),
9124 ControlEventKind::ResumeDenied { reason } => {
9125 return Err(failure(
9126 NodeFailureCode::SessionRecordNotResumable,
9127 &reason,
9128 ));
9129 }
9130 ControlEventKind::ResumeFailed { message }
9131 | ControlEventKind::CommandRejected { message } => {
9132 return Err(failure(
9133 NodeFailureCode::BackendOperationFailed,
9134 &message,
9135 ));
9136 }
9137 _ => {}
9138 }
9139 }
9140 if Instant::now() >= deadline {
9141 return Err(failure(
9142 NodeFailureCode::BackendBusy,
9143 "managed resume did not settle before the bounded deadline",
9144 ));
9145 }
9146 sleep(Duration::from_millis(5)).await;
9147 }
9148 }
9149
9150 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9151 self.workspaces
9152 .read()
9153 .unwrap_or_else(|poisoned| poisoned.into_inner())
9154 .get(workspace_id)
9155 .cloned()
9156 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9157 }
9158
9159 fn workspace_roots(&self) -> Vec<PathBuf> {
9160 self.workspaces
9161 .read()
9162 .unwrap_or_else(|poisoned| poisoned.into_inner())
9163 .values()
9164 .map(PathBuf::from)
9165 .collect()
9166 }
9167
9168 async fn inspect_workspace(
9169 &self,
9170 workspace_id: WorkspaceId,
9171 ) -> Result<WorkspaceInspection, NodeFailure> {
9172 let _permit = self
9173 .inspection_slots
9174 .clone()
9175 .try_acquire_owned()
9176 .map_err(|_| {
9177 tracing::warn!(
9178 workspace_id = %workspace_id,
9179 "workspace inspection rejected: inspection capacity is busy",
9180 );
9181 failure(
9182 NodeFailureCode::BackendBusy,
9183 "workspace inspection capacity is busy",
9184 )
9185 })?;
9186 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9187 tracing::warn!(
9188 workspace_id = %workspace_id,
9189 code = ?error.code,
9190 "workspace inspection rejected: workspace is not registered",
9191 );
9192 error
9193 })?;
9194 let tree_root = canonical_root.clone();
9195 let started_at = Instant::now();
9196 let time_budget_ms = workspace_inspection_time_budget_ms();
9197 let entry_cap = workspace_inspection_entry_cap();
9198 let deadline = started_at + Duration::from_millis(time_budget_ms);
9199 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9200 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9201 })
9202 .await
9203 .map_err(|error| {
9204 tracing::warn!(
9205 workspace_id = %workspace_id,
9206 error = %error,
9207 "workspace inspection rejected: walk task failed",
9208 );
9209 failure(
9210 NodeFailureCode::BackendOperationFailed,
9211 &format!("workspace inspection task failed: {error}"),
9212 )
9213 })?;
9214 let (mut git, git_time_budget_exceeded) =
9215 inspect_git_workspace(&canonical_root, deadline).await;
9216 if !git.worktrees.is_empty() {
9217 let registered = self
9218 .workspaces
9219 .read()
9220 .unwrap_or_else(|poisoned| poisoned.into_inner())
9221 .iter()
9222 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9223 .collect::<Vec<_>>();
9224 for worktree in &mut git.worktrees {
9225 worktree.workspace_id = registered
9226 .iter()
9227 .find(|(_, root)| {
9228 worktree_paths_equal(root, windows_path_text(&worktree.path))
9229 })
9230 .map(|(workspace_id, _)| workspace_id.clone());
9231 }
9232 }
9233 if git.is_repository {
9234 git.managed_worktree = self
9235 .managed_worktrees
9236 .lock()
9237 .unwrap_or_else(|poisoned| poisoned.into_inner())
9238 .git_scope(&workspace_id, git.branch.as_deref());
9239 }
9240 let truncation = workspace_inspection_truncation(
9241 &walk_budget,
9242 git_time_budget_exceeded,
9243 started_at.elapsed(),
9244 );
9245 if let Some(truncation) = &truncation {
9246 tracing::info!(
9247 workspace_id = %workspace_id,
9248 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9249 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9250 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9251 entries_visited = truncation.entries_visited,
9252 elapsed_ms = truncation.elapsed_ms,
9253 "workspace inspection truncated by its inner budget",
9254 );
9255 }
9256 Ok(WorkspaceInspection {
9257 workspace_id,
9258 entries,
9259 tree_truncated,
9260 git,
9261 truncation,
9262 })
9263 }
9264
9265 async fn browse_host_directories(
9266 &self,
9267 directory: Option<OpaqueHostPath>,
9268 after: Option<OpaqueHostPath>,
9269 ) -> Result<HostDirectoryListing, NodeFailure> {
9270 let permit = self
9271 .inspection_slots
9272 .clone()
9273 .try_acquire_owned()
9274 .map_err(|_| {
9275 failure(
9276 NodeFailureCode::BackendBusy,
9277 "host directory browse capacity is busy",
9278 )
9279 })?;
9280 let task = tokio::task::spawn_blocking(move || {
9281 let _permit = permit;
9282 browse_host_directories(directory, after)
9283 });
9284 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9285 .await
9286 .map_err(|_| {
9287 failure(
9288 NodeFailureCode::HostDirectoryReadTimedOut,
9289 "host directory browse exceeded its bounded deadline",
9290 )
9291 })?
9292 .map_err(|_| {
9293 failure(
9294 NodeFailureCode::HostDirectoryReadFailed,
9295 "host directory browse task failed",
9296 )
9297 })?
9298 .map_err(host_directory_failure)
9299 }
9300
9301 async fn read_workspace_file(
9302 &self,
9303 workspace_id: WorkspaceId,
9304 path: RepositoryPath,
9305 ) -> Result<WorkspaceFileRead, NodeFailure> {
9306 let canonical_root = self.workspace_root(&workspace_id)?;
9307 let permit = self
9308 .inspection_slots
9309 .clone()
9310 .try_acquire_owned()
9311 .map_err(|_| {
9312 failure(
9313 NodeFailureCode::BackendBusy,
9314 "workspace file read capacity is busy",
9315 )
9316 })?;
9317 let path_to_read = path.clone();
9318 let task = tokio::task::spawn_blocking(move || {
9319 let _permit = permit;
9320 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9321 });
9322 let content = timeout(
9323 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9324 task,
9325 )
9326 .await
9327 .map_err(|_| {
9328 failure(
9329 NodeFailureCode::RepositoryFileReadTimedOut,
9330 "repository file read exceeded its bounded deadline",
9331 )
9332 })?
9333 .map_err(|_| {
9334 failure(
9335 NodeFailureCode::RepositoryFileReadFailed,
9336 "repository file read task failed",
9337 )
9338 })?
9339 .map_err(workspace_file_failure)?;
9340 let content = match content {
9341 WorkspaceFileBytes::Utf8(text) => {
9342 let revision = Some(workspace_file_revision(text.as_bytes()));
9343 return Ok(WorkspaceFileRead {
9344 workspace_id,
9345 path,
9346 content: WorkspaceFileContent::Utf8 {
9347 byte_len: u32::try_from(text.len())
9348 .expect("bounded workspace text length must fit u32"),
9349 text,
9350 },
9351 revision,
9352 });
9353 }
9354 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9355 WorkspaceFileContent::NonUtf8 {
9356 byte_len: u32::try_from(byte_length)
9357 .expect("bounded workspace file length must fit u32"),
9358 }
9359 }
9360 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9361 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9362 },
9363 };
9364 Ok(WorkspaceFileRead {
9365 workspace_id,
9366 path,
9367 content,
9368 revision: None,
9369 })
9370 }
9371
9372 async fn write_workspace_file(
9373 &self,
9374 workspace_id: WorkspaceId,
9375 path: RepositoryPath,
9376 expected_revision: WorkspaceFileRevision,
9377 text: String,
9378 ) -> Result<WorkspaceFileRead, NodeFailure> {
9379 let canonical_root = self.workspace_root(&workspace_id)?;
9380 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9381 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9382 })?;
9383 let path_to_write = path.clone();
9384 let expected = expected_revision.as_str().to_owned();
9385 let text_to_write = text.clone();
9386 let task = tokio::task::spawn_blocking(move || {
9387 let _permit = permit;
9388 write_workspace_file_to_disk(
9389 Path::new(&canonical_root),
9390 &path_to_write,
9391 &expected,
9392 &text_to_write,
9393 )
9394 });
9395 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9396 .await
9397 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9398 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9399 .map_err(workspace_file_write_failure)?;
9400 Ok(WorkspaceFileRead {
9401 workspace_id,
9402 path,
9403 content: WorkspaceFileContent::Utf8 {
9404 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9405 text,
9406 },
9407 revision: Some(WorkspaceFileRevision::new(revision)
9408 .expect("workspace writer returns a valid SHA-256 revision")),
9409 })
9410 }
9411
9412 async fn create_workspace_file(
9413 &self,
9414 workspace_id: WorkspaceId,
9415 path: RepositoryPath,
9416 ) -> Result<WorkspaceFileRead, NodeFailure> {
9417 let canonical_root = self.workspace_root(&workspace_id)?;
9418 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9420 })?;
9421 let path_to_create = path.clone();
9422 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9423 let task_commit_state = Arc::clone(&commit_state);
9424 let task = tokio::task::spawn_blocking(move || {
9425 let _permit = permit;
9426 create_workspace_file_on_disk(
9427 Path::new(&canonical_root),
9428 &path_to_create,
9429 &task_commit_state,
9430 )
9431 });
9432 let revision = settle_workspace_entry_create(
9433 task,
9434 commit_state,
9435 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9436 "repository file create exceeded its bounded deadline",
9437 "repository file create task failed",
9438 )
9439 .await?;
9440 Ok(WorkspaceFileRead {
9441 workspace_id,
9442 path,
9443 content: WorkspaceFileContent::Utf8 {
9444 text: String::new(),
9445 byte_len: 0,
9446 },
9447 revision: Some(WorkspaceFileRevision::new(revision)
9448 .expect("workspace creator returns a valid SHA-256 revision")),
9449 })
9450 }
9451
9452 async fn create_workspace_directory(
9453 &self,
9454 workspace_id: WorkspaceId,
9455 path: RepositoryPath,
9456 ) -> Result<WorkspaceEntry, NodeFailure> {
9457 let canonical_root = self.workspace_root(&workspace_id)?;
9458 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9459 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9460 })?;
9461 let path_to_create = path.clone();
9462 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9463 let task_commit_state = Arc::clone(&commit_state);
9464 let task = tokio::task::spawn_blocking(move || {
9465 let _permit = permit;
9466 create_workspace_directory_on_disk(
9467 Path::new(&canonical_root),
9468 &path_to_create,
9469 &task_commit_state,
9470 )
9471 });
9472 settle_workspace_entry_create(
9473 task,
9474 commit_state,
9475 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9476 "repository directory create exceeded its bounded deadline",
9477 "repository directory create task failed",
9478 )
9479 .await?;
9480 Ok(WorkspaceEntry {
9481 relative_path: path,
9482 kind: WorkspaceEntryKind::Directory,
9483 })
9484 }
9485
9486 async fn read_git_history(
9487 &self,
9488 workspace_id: WorkspaceId,
9489 path: Option<RepositoryPath>,
9490 before: Option<GitObjectId>,
9491 limit: u16,
9492 ) -> Result<GitHistoryPage, NodeFailure> {
9493 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9494 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9495 })?;
9496 let root = self.workspace_root(&workspace_id)?;
9497 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9498 }
9499
9500 async fn read_git_diff(
9501 &self,
9502 workspace_id: WorkspaceId,
9503 request: GitDiffRequest,
9504 ) -> Result<GitDiff, NodeFailure> {
9505 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9506 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9507 })?;
9508 let root = self.workspace_root(&workspace_id)?;
9509 read_git_diff_bounded(&root, request).await
9510 }
9511
9512 fn native_session_route_root(
9513 &self,
9514 route: &NativeSessionCatalogRoute,
9515 ) -> Result<Option<String>, NodeFailure> {
9516 route
9517 .validate()
9518 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9519 match route.scope {
9520 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9521 .workspace_id
9522 .as_ref()
9523 .map(|workspace_id| self.workspace_root(workspace_id))
9524 .transpose(),
9525 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9526 }
9527 }
9528
9529 fn project_native_session_entries(
9530 &self,
9531 route: &NativeSessionCatalogRoute,
9532 entries: Vec<ScopedNativeSessionCatalogEntry>,
9533 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9534 let current_root = route
9535 .workspace_id
9536 .as_ref()
9537 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9538 let records = self
9539 .session_records
9540 .lock()
9541 .unwrap_or_else(|poisoned| poisoned.into_inner());
9542 let mut seen_records = std::collections::HashSet::new();
9543 entries
9544 .into_iter()
9545 .map(|entry| {
9546 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9547 let matches = records
9548 .records
9549 .values()
9550 .filter(|record| {
9551 record.provider == route.provider
9552 && record.workspace_id == *workspace_id
9553 && current_root.as_deref().is_some_and(|root| {
9554 platform::roots_equal(
9555 root,
9556 windows_path_text(&record.canonical_root),
9557 )
9558 })
9559 && record.provider_session.as_ref().is_some_and(|identity| {
9560 session_registry::same_provider_session(
9561 &record.provider,
9562 identity,
9563 &route.provider,
9564 &entry.provider_session,
9565 )
9566 })
9567 })
9568 .map(|record| record.record_id.clone())
9569 .collect::<Vec<_>>();
9570 (matches.len() == 1
9571 && seen_records.insert(matches[0].clone()))
9572 .then(|| matches[0].clone())
9573 });
9574 let metadata = entry.metadata;
9575 crate::protocol::NativeSessionCatalogEntry {
9576 selection_id: metadata.selection_id,
9577 title: metadata.title,
9578 modified_at_unix_ms: metadata.modified_at_unix_ms,
9579 model: metadata.model,
9580 message_count: metadata.message_count,
9581 completed_turn_count: metadata.completed_turn_count,
9582 external_group: entry.external_group,
9583 record_id,
9584 }
9585 })
9586 .collect()
9587 }
9588
9589 async fn catalog_native_sessions(
9590 &self,
9591 route: NativeSessionCatalogRoute,
9592 limit: u16,
9593 ) -> Result<(
9594 Vec<crate::protocol::NativeSessionCatalogEntry>,
9595 crate::protocol::NativeSessionCatalogSummary,
9596 ), NodeFailure> {
9597 let canonical_root = self.native_session_route_root(&route)?;
9598 let registered_workspace_roots = self.workspace_roots();
9599 let provider = route.provider.clone();
9600 let scope = route.scope;
9601 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9602 failure(
9603 NodeFailureCode::InvalidRequest,
9604 "native session catalog is not configured",
9605 )
9606 })?;
9607 let _permit = Arc::clone(&self.inspection_slots)
9608 .try_acquire_owned()
9609 .map_err(|_| {
9610 failure(
9611 NodeFailureCode::BackendBusy,
9612 "native session catalog capacity is busy",
9613 )
9614 })?;
9615 let page = run_native_session_catalog_operation(
9616 catalog,
9617 "native session catalog",
9618 move |catalog| {
9619 catalog.catalog_initial_for_scope(
9620 &provider,
9621 scope,
9622 canonical_root.as_deref().map(Path::new),
9623 ®istered_workspace_roots,
9624 limit,
9625 )
9626 },
9627 )
9628 .await?;
9629 let summary = crate::protocol::NativeSessionCatalogSummary {
9630 catalog_revision: page.revision,
9631 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9632 recent_total_count: page.recent_total_count,
9633 older_total_count: page.older_total_count,
9634 recent_next_after_selection_id: page.next_after_selection_id,
9635 recent_has_more: page.remaining_count > 0,
9636 };
9637 Ok((self.project_native_session_entries(&route, page.entries), summary))
9638 }
9639
9640 async fn page_native_sessions(
9641 &self,
9642 route: NativeSessionCatalogRoute,
9643 window: crate::protocol::NativeSessionCatalogWindow,
9644 catalog_revision: u64,
9645 recent_cutoff_unix_ms: u64,
9646 after_selection_id: Option<String>,
9647 limit: u16,
9648 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9649 let canonical_root = self.native_session_route_root(&route)?;
9650 let registered_workspace_roots = self.workspace_roots();
9651 let provider = route.provider.clone();
9652 let scope = route.scope;
9653 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9654 failure(
9655 NodeFailureCode::InvalidRequest,
9656 "native session catalog is not configured",
9657 )
9658 })?;
9659 let _permit = Arc::clone(&self.inspection_slots)
9660 .try_acquire_owned()
9661 .map_err(|_| {
9662 failure(
9663 NodeFailureCode::BackendBusy,
9664 "native session catalog capacity is busy",
9665 )
9666 })?;
9667 let page = run_native_session_catalog_operation(
9668 catalog,
9669 "native session catalog page",
9670 move |catalog| {
9671 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9672 &provider,
9673 scope,
9674 canonical_root.as_deref().map(Path::new),
9675 ®istered_workspace_roots,
9676 window,
9677 catalog_revision,
9678 recent_cutoff_unix_ms,
9679 after_selection_id.as_deref(),
9680 limit,
9681 ))
9682 },
9683 )
9684 .await?;
9685 let page = page.map_err(|error| match error {
9686 NativeSessionCatalogError::StaleCatalog => failure(
9687 NodeFailureCode::StaleNativeSessionCatalog,
9688 "native session catalog revision or cursor is stale",
9689 ),
9690 _ => failure(
9691 NodeFailureCode::BackendOperationFailed,
9692 "native session catalog page failed",
9693 ),
9694 })?;
9695 let entries = self.project_native_session_entries(&route, page.entries);
9696 Ok(crate::protocol::NativeSessionCatalogPage {
9697 window: page.window,
9698 revision: page.revision,
9699 entries,
9700 next_after_selection_id: page.next_after_selection_id,
9701 remaining_count: page.remaining_count,
9702 has_more: page.remaining_count > 0,
9703 })
9704 }
9705
9706 async fn preview_native_session(
9707 &self,
9708 selection: NativeSessionSelection,
9709 message_limit: u16,
9710 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9711 selection
9712 .validate()
9713 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9714 let canonical_root = self.native_session_route_root(&selection.route)?;
9715 let registered_workspace_roots = self.workspace_roots();
9716 let provider = selection.route.provider.clone();
9717 let scope = selection.route.scope;
9718 let catalog_revision = selection.catalog_revision;
9719 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9720 let selection_id = selection.selection_id.clone();
9721 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9722 failure(
9723 NodeFailureCode::InvalidRequest,
9724 "native session preview is not configured",
9725 )
9726 })?;
9727 let _permit = Arc::clone(&self.inspection_slots)
9728 .try_acquire_owned()
9729 .map_err(|_| {
9730 failure(
9731 NodeFailureCode::BackendBusy,
9732 "native session preview capacity is busy",
9733 )
9734 })?;
9735 let preview = run_native_session_catalog_operation(
9736 catalog,
9737 "native session preview",
9738 move |catalog| {
9739 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9740 &provider,
9741 scope,
9742 canonical_root.as_deref().map(Path::new),
9743 ®istered_workspace_roots,
9744 catalog_revision,
9745 recent_cutoff_unix_ms,
9746 &selection_id,
9747 message_limit,
9748 ))
9749 },
9750 )
9751 .await?;
9752 preview
9753 .map(Into::into)
9754 .map_err(native_session_preview_failure)
9755 }
9756
9757 async fn index_native_session(
9758 &self,
9759 selection: NativeSessionSelection,
9760 display_name: String,
9761 ) -> Result<ManagedSessionRecord, NodeFailure> {
9762 selection
9763 .validate()
9764 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9765 if selection.route.scope
9766 != gate4agent_types::NativeSessionCatalogScope::Workspace
9767 {
9768 return Err(failure(
9769 NodeFailureCode::WorkspaceRegistrationRequired,
9770 "register the external project as a workspace before indexing or resuming it",
9771 ));
9772 }
9773 let workspace_id = selection
9774 .route
9775 .workspace_id
9776 .clone()
9777 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9778 let canonical_root = self
9779 .native_session_route_root(&selection.route)?
9780 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9781 let registered_workspace_roots = self.workspace_roots();
9782 let provider = selection.route.provider.clone();
9783 let catalog_revision = selection.catalog_revision;
9784 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9785 let selection_id = selection.selection_id.clone();
9786 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9787 failure(
9788 NodeFailureCode::InvalidRequest,
9789 "native session index is not configured",
9790 )
9791 })?;
9792 let _permit = Arc::clone(&self.inspection_slots)
9793 .try_acquire_owned()
9794 .map_err(|_| {
9795 failure(
9796 NodeFailureCode::BackendBusy,
9797 "native session index capacity is busy",
9798 )
9799 })?;
9800 let resolved = run_native_session_catalog_operation(
9801 catalog,
9802 "native session index resolution",
9803 move |catalog| {
9804 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9805 &provider,
9806 gate4agent_types::NativeSessionCatalogScope::Workspace,
9807 Some(Path::new(&canonical_root)),
9808 ®istered_workspace_roots,
9809 catalog_revision,
9810 recent_cutoff_unix_ms,
9811 &selection_id,
9812 ))
9813 },
9814 )
9815 .await?
9816 .map_err(native_session_preview_failure)?;
9817 self.index_provider_session_with_policy(
9818 workspace_id,
9819 selection.route.provider,
9820 resolved.identity,
9821 display_name,
9822 true,
9823 )
9824 }
9825
9826 async fn preview_native_session_by_session_id(
9827 &self,
9828 workspace_id: WorkspaceId,
9829 provider: AgentId,
9830 session_id: String,
9831 message_limit: u16,
9832 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9833 let canonical_root = self.workspace_root(&workspace_id)?;
9834 let registered_workspace_roots = self.workspace_roots();
9835 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9836 failure(
9837 NodeFailureCode::InvalidRequest,
9838 "native session preview is not configured",
9839 )
9840 })?;
9841 let _permit = Arc::clone(&self.inspection_slots)
9842 .try_acquire_owned()
9843 .map_err(|_| {
9844 failure(
9845 NodeFailureCode::BackendBusy,
9846 "native session preview capacity is busy",
9847 )
9848 })?;
9849 run_native_session_catalog_operation(
9850 catalog,
9851 "native session preview",
9852 move |catalog| {
9853 catalog.preview_session_id_for_workspace(
9854 &provider,
9855 Path::new(&canonical_root),
9856 ®istered_workspace_roots,
9857 &session_id,
9858 message_limit,
9859 )
9860 },
9861 )
9862 .await
9863 }
9864
9865 async fn register_workspace(
9866 &self,
9867 workspace_id: WorkspaceId,
9868 root: String,
9869 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9870 validate_workspace_request_root(&root)?;
9871 let canonicalize_workspace_id = workspace_id.clone();
9872 let workspace = tokio::task::spawn_blocking(move || {
9873 WorkspaceConfig::new(canonicalize_workspace_id, root)
9874 })
9875 .await
9876 .map_err(|error| {
9877 failure(
9878 NodeFailureCode::BackendOperationFailed,
9879 &format!("workspace canonicalization task failed: {error}"),
9880 )
9881 })?
9882 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9883 let transaction = self
9884 .state_transaction
9885 .lock()
9886 .unwrap_or_else(|poisoned| poisoned.into_inner());
9887 let mut workspaces = self
9888 .workspaces
9889 .write()
9890 .unwrap_or_else(|poisoned| poisoned.into_inner());
9891 if workspaces.contains_key(&workspace_id) {
9892 return Err(failure(
9893 NodeFailureCode::DuplicateWorkspaceId,
9894 "workspace ID is already registered",
9895 ));
9896 }
9897 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9898 platform::roots_equal(existing_root, workspace.canonical_root())
9899 }) {
9900 return Err(failure(
9901 NodeFailureCode::DuplicateWorkspaceRoot,
9902 &format!("workspace root is already registered as '{existing_id}'"),
9903 ));
9904 }
9905 {
9906 let records = self
9907 .session_records
9908 .lock()
9909 .unwrap_or_else(|poisoned| poisoned.into_inner());
9910 if let Some(record) = records.records.values().find(|record| {
9911 record.workspace_id == workspace_id
9912 && !record
9913 .canonical_root
9914 .as_utf8()
9915 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9916 }) {
9917 return Err(failure(
9918 NodeFailureCode::SessionRecordConflict,
9919 &format!(
9920 "workspace ID is retained by session record '{}' at another root",
9921 record.record_id,
9922 ),
9923 ));
9924 }
9925 }
9926 let snapshot = WorkspaceSnapshot {
9927 workspace_id: workspace_id.clone(),
9928 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9929 sessions: Vec::new(),
9930 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9931 managed_worktree_profiles: Some(WorktreeProfileInventory {
9932 profiles: Vec::new(),
9933 }),
9934 };
9935 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9936 drop(workspaces);
9937 let previous_records = {
9938 let mut records = self
9939 .session_records
9940 .lock()
9941 .unwrap_or_else(|poisoned| poisoned.into_inner());
9942 let previous = records.records.clone();
9943 for record in records.records.values_mut().filter(|record| {
9944 record.workspace_id == workspace_id
9945 && record
9946 .canonical_root
9947 .as_utf8()
9948 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9949 }) {
9950 if record.active_session.is_none() {
9951 record.state = detached_record_state(record);
9952 }
9953 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9954 record.last_error = None;
9955 }
9956 record.updated_at_unix_ms = unix_time_ms();
9957 }
9958 previous
9959 };
9960 if let Err(error) = self.persist_state_locked() {
9961 self.workspaces
9962 .write()
9963 .unwrap_or_else(|poisoned| poisoned.into_inner())
9964 .remove(&workspace_id);
9965 self.session_records
9966 .lock()
9967 .unwrap_or_else(|poisoned| poisoned.into_inner())
9968 .records = previous_records;
9969 return Err(persistence_failure(error));
9970 }
9971 let restored = self
9972 .session_records
9973 .lock()
9974 .unwrap_or_else(|poisoned| poisoned.into_inner())
9975 .records
9976 .values()
9977 .filter(|record| record.workspace_id == workspace_id)
9978 .cloned()
9979 .collect::<Vec<_>>();
9980 drop(transaction);
9981 self.publish(NodeEvent::WorkspaceAdded {
9982 workspace: snapshot.clone(),
9983 });
9984 for record in restored {
9985 self.publish_record(record);
9986 }
9987 Ok(snapshot)
9988 }
9989
9990 async fn create_standalone_workspace(
9991 &self,
9992 workspace_id: WorkspaceId,
9993 root: String,
9994 initial_branch: Option<String>,
9995 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9996 validate_workspace_request_root(&root)?;
9997 validate_git_revision("initial branch", initial_branch.as_deref())?;
9998 let preflight_workspace_id = workspace_id.clone();
9999 let preflight_root = root.clone();
10000 let canonical_candidate = tokio::task::spawn_blocking(move || {
10001 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10002 })
10003 .await
10004 .map_err(|_| failure(
10005 NodeFailureCode::BackendOperationFailed,
10006 "standalone workspace root preflight task failed",
10007 ))?
10008 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10009 {
10010 let workspaces = self
10011 .workspaces
10012 .read()
10013 .unwrap_or_else(|poisoned| poisoned.into_inner());
10014 if workspaces.contains_key(&workspace_id) {
10015 return Err(failure(
10016 NodeFailureCode::DuplicateWorkspaceId,
10017 "workspace ID is already registered",
10018 ));
10019 }
10020 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10021 platform::roots_equal(existing_root, &canonical_candidate)
10022 }) {
10023 return Err(failure(
10024 NodeFailureCode::DuplicateWorkspaceRoot,
10025 &format!("workspace root is already registered as '{existing_id}'"),
10026 ));
10027 }
10028 }
10029 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10030 .await
10031 .map_err(standalone_workspace_failure)?;
10032 prepared
10033 .verify_for_registration()
10034 .await
10035 .map_err(standalone_workspace_failure)?;
10036 match self
10037 .register_workspace(workspace_id, prepared.root().to_owned())
10038 .await
10039 {
10040 Ok(workspace) => Ok(workspace),
10041 Err(registration_error) => {
10042 if prepared.compensate_registration_failure().await {
10043 Err(registration_error)
10044 } else {
10045 Err(failure(
10046 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10047 "standalone workspace initialization succeeded but registration failed; recovery is required",
10048 ))
10049 }
10050 }
10051 }
10052 }
10053
10054 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10055 let bindings = self
10056 .session_bindings
10057 .lock()
10058 .unwrap_or_else(|poisoned| poisoned.into_inner());
10059 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10060 return Err(failure(
10061 NodeFailureCode::WorkspaceBusy,
10062 "workspace retains a bound session; remove every session before unregistering it",
10063 ));
10064 }
10065 drop(bindings);
10066 let transaction = self
10067 .state_transaction
10068 .lock()
10069 .unwrap_or_else(|poisoned| poisoned.into_inner());
10070 let mut workspaces = self
10071 .workspaces
10072 .write()
10073 .unwrap_or_else(|poisoned| poisoned.into_inner());
10074 if !workspaces.contains_key(workspace_id) {
10075 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10076 }
10077 if workspaces.len() == 1 {
10078 return Err(failure(
10079 NodeFailureCode::LastWorkspace,
10080 "node must retain at least one workspace",
10081 ));
10082 }
10083 let removed_root = workspaces
10084 .remove(workspace_id)
10085 .expect("workspace presence was validated");
10086 drop(workspaces);
10087 let previous_records = {
10088 let mut records = self
10089 .session_records
10090 .lock()
10091 .unwrap_or_else(|poisoned| poisoned.into_inner());
10092 let previous = records.records.clone();
10093 for record in records
10094 .records
10095 .values_mut()
10096 .filter(|record| &record.workspace_id == workspace_id)
10097 {
10098 record.active_session = None;
10099 record.state = ManagedSessionState::Unavailable;
10100 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10101 record.updated_at_unix_ms = unix_time_ms();
10102 }
10103 previous
10104 };
10105 if let Err(error) = self.persist_state_locked() {
10106 self.workspaces
10107 .write()
10108 .unwrap_or_else(|poisoned| poisoned.into_inner())
10109 .insert(workspace_id.clone(), removed_root);
10110 self.session_records
10111 .lock()
10112 .unwrap_or_else(|poisoned| poisoned.into_inner())
10113 .records = previous_records;
10114 return Err(persistence_failure(error));
10115 }
10116 let unavailable = self
10117 .session_records
10118 .lock()
10119 .unwrap_or_else(|poisoned| poisoned.into_inner())
10120 .records
10121 .values()
10122 .filter(|record| &record.workspace_id == workspace_id)
10123 .cloned()
10124 .collect::<Vec<_>>();
10125 drop(transaction);
10126 self.publish(NodeEvent::WorkspaceRemoved {
10127 workspace_id: workspace_id.clone(),
10128 });
10129 for record in unavailable {
10130 self.publish_record(record);
10131 }
10132 Ok(())
10133 }
10134
10135 async fn create_worktree(
10136 &self,
10137 source_workspace_id: WorkspaceId,
10138 workspace_id: WorkspaceId,
10139 target_root: String,
10140 branch: String,
10141 base: Option<String>,
10142 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10143 self.require_worktree_service(&source_workspace_id)?;
10144 validate_workspace_request_root(&target_root)?;
10145 validate_git_revision("worktree base", base.as_deref())?;
10146 let source_root = self.workspace_root(&source_workspace_id)?;
10147 {
10148 let workspaces = self
10149 .workspaces
10150 .read()
10151 .unwrap_or_else(|poisoned| poisoned.into_inner());
10152 if workspaces.contains_key(&workspace_id) {
10153 return Err(failure(
10154 NodeFailureCode::DuplicateWorkspaceId,
10155 "workspace ID is already registered",
10156 ));
10157 }
10158 if let Some((existing_id, _)) = workspaces
10159 .iter()
10160 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10161 {
10162 return Err(failure(
10163 NodeFailureCode::DuplicateWorkspaceRoot,
10164 &format!("workspace root is already registered as '{existing_id}'"),
10165 ));
10166 }
10167 }
10168 let mut worktree = create_git_worktree(
10169 &source_root,
10170 &target_root,
10171 &branch,
10172 base.as_deref(),
10173 )
10174 .await
10175 .map_err(git_worktree_failure)?;
10176 let workspace = match self
10177 .register_workspace(workspace_id, worktree.path.clone())
10178 .await
10179 {
10180 Ok(workspace) => workspace,
10181 Err(registration_error) => {
10182 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10183 return Err(failure(
10184 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10185 "worktree registration failed and compensation requires recovery",
10186 ));
10187 }
10188 return Err(failure(
10189 registration_error.code,
10190 ®istration_error.message,
10191 ));
10192 }
10193 };
10194 worktree.workspace_id = Some(workspace.workspace_id.clone());
10195 Ok((protocol_worktree(worktree), workspace))
10196 }
10197
10198 async fn remove_worktree(
10199 &self,
10200 source_workspace_id: WorkspaceId,
10201 target_root: String,
10202 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10203 self.require_worktree_service(&source_workspace_id)?;
10204 validate_workspace_request_root(&target_root)?;
10205 let source_root = self.workspace_root(&source_workspace_id)?;
10206 let target_root = normalize_worktree_removal_target(&target_root)
10207 .map_err(git_worktree_failure)?;
10208 let target = list_git_worktrees(&source_root)
10209 .await
10210 .map_err(git_worktree_failure)?
10211 .into_iter()
10212 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10213 .ok_or_else(|| {
10214 failure(
10215 NodeFailureCode::WorktreeProtected,
10216 "refusing to remove a path that is not in Git's worktree listing",
10217 )
10218 })?;
10219 let registered_workspace_id = {
10220 let workspaces = self
10221 .workspaces
10222 .read()
10223 .unwrap_or_else(|poisoned| poisoned.into_inner());
10224 let matched = workspaces
10225 .iter()
10226 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10227 .map(|(workspace_id, _)| workspace_id.clone());
10228 if matched.is_some() && workspaces.len() == 1 {
10229 return Err(failure(
10230 NodeFailureCode::LastWorkspace,
10231 "node must retain at least one workspace",
10232 ));
10233 }
10234 matched
10235 };
10236 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10237 let bindings = self
10238 .session_bindings
10239 .lock()
10240 .unwrap_or_else(|poisoned| poisoned.into_inner());
10241 if bindings
10242 .values()
10243 .any(|binding| &binding.workspace_id == workspace_id)
10244 {
10245 return Err(failure(
10246 NodeFailureCode::WorkspaceBusy,
10247 "worktree workspace retains a bound session; remove every session first",
10248 ));
10249 }
10250 }
10251 remove_git_worktree(&source_root, &target.path)
10252 .await
10253 .map_err(git_worktree_failure)?;
10254 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255 self.unregister_workspace(workspace_id)
10256 .map_err(|error| failure(error.code, &error.message))?;
10257 }
10258 Ok(registered_workspace_id)
10259 }
10260
10261 async fn cleanup_managed_worktree(
10262 &self,
10263 lease_id: &ManagedWorktreeLeaseId,
10264 explicit: bool,
10265 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10266 let lease = self.managed_worktrees.lock()
10267 .unwrap_or_else(|poisoned| poisoned.into_inner())
10268 .get(lease_id)
10269 .cloned()
10270 .ok_or_else(|| failure(
10271 NodeFailureCode::UnknownManagedWorktreeLease,
10272 "managed worktree lease does not exist",
10273 ))?;
10274 if lease.state == ManagedWorktreeLeaseState::Removed {
10275 return Ok(lease.snapshot());
10276 }
10277 if lease.has_holders() {
10278 return Err(failure(
10279 NodeFailureCode::ManagedWorktreeBusy,
10280 "managed worktree lease still has a session or durable-record holder",
10281 ));
10282 }
10283 if self
10284 .materializations
10285 .lock()
10286 .unwrap_or_else(|poisoned| poisoned.into_inner())
10287 .values()
10288 .any(|record| record.managed_lease_id() == Some(lease_id))
10289 {
10290 return Err(failure(
10291 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10292 "managed worktree cleanup is blocked by session environment ownership",
10293 ));
10294 }
10295 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10296 let snapshot = {
10297 let mut registry = self.managed_worktrees.lock()
10298 .unwrap_or_else(|poisoned| poisoned.into_inner());
10299 let current = registry.get_mut(lease_id)
10300 .expect("managed worktree lease remains present");
10301 current.state = ManagedWorktreeLeaseState::Retained;
10302 current.cleanup_failure = None;
10303 current.updated_at_unix_ms = unix_time_ms();
10304 current.snapshot()
10305 };
10306 if self.persist_state().is_err() {
10307 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10308 }
10309 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10310 return Ok(snapshot);
10311 }
10312 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10313 if profile.revision() != &lease.profile_revision {
10314 self.mark_managed_recovery_required(
10315 lease_id,
10316 ManagedWorktreeCleanupFailure::OwnershipConflict,
10317 );
10318 return Err(failure(
10319 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10320 "managed worktree profile revision changed",
10321 ));
10322 }
10323 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10324 self.mark_managed_recovery_required(
10325 lease_id,
10326 ManagedWorktreeCleanupFailure::OwnershipConflict,
10327 );
10328 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10329 })?;
10330 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10331 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10332 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10333 managed_git_worktree_failure(error)
10334 })?;
10335 let target = listed.iter()
10336 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10337 if let Some(target) = target {
10338 if target.is_main || target.is_bare || !exact_owned_worktree(
10339 &lease,
10340 &target.path,
10341 target.branch.as_deref(),
10342 &target.head,
10343 ) {
10344 self.mark_managed_recovery_required(
10345 lease_id,
10346 ManagedWorktreeCleanupFailure::OwnershipConflict,
10347 );
10348 return Err(failure(
10349 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10350 "Git worktree identity no longer matches the managed lease",
10351 ));
10352 }
10353 if target.prunable {
10354 self.mark_managed_cleanup_blocked(
10355 lease_id,
10356 ManagedWorktreeCleanupFailure::Prunable,
10357 );
10358 return Err(failure(
10359 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10360 "managed worktree is prunable and requires recovery",
10361 ));
10362 }
10363 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10364 let failure_kind = match error.kind {
10365 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10366 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10367 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10368 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10369 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10370 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10371 };
10372 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10373 self.mark_managed_recovery_required(lease_id, failure_kind);
10374 } else {
10375 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10376 }
10377 return Err(managed_git_worktree_failure(error));
10378 }
10379 } else {
10380 match std::fs::symlink_metadata(&lease.target_root) {
10381 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10382 Ok(_) => {
10383 self.mark_managed_recovery_required(
10384 lease_id,
10385 ManagedWorktreeCleanupFailure::OwnershipConflict,
10386 );
10387 return Err(failure(
10388 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10389 "managed target exists but is not owned by Git",
10390 ));
10391 }
10392 Err(_) => {
10393 self.mark_managed_cleanup_blocked(
10394 lease_id,
10395 ManagedWorktreeCleanupFailure::Backend,
10396 );
10397 return Err(failure(
10398 NodeFailureCode::BackendOperationFailed,
10399 "managed target identity could not be inspected",
10400 ));
10401 }
10402 }
10403 }
10404 let registered = self.workspaces.read()
10405 .unwrap_or_else(|poisoned| poisoned.into_inner())
10406 .contains_key(&lease.workspace_id);
10407 if registered {
10408 self.unregister_workspace(&lease.workspace_id)?;
10409 }
10410 let removed = {
10411 self.managed_worktrees.lock()
10412 .unwrap_or_else(|poisoned| poisoned.into_inner())
10413 .tombstone(lease_id, unix_time_ms())
10414 .expect("managed worktree lease remains present during cleanup")
10415 };
10416 self.persist_state().map_err(persistence_failure)?;
10417 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10418 Ok(removed.snapshot())
10419 }
10420
10421 fn mark_managed_cleanup_blocked(
10422 &self,
10423 lease_id: &ManagedWorktreeLeaseId,
10424 failure_kind: ManagedWorktreeCleanupFailure,
10425 ) {
10426 let snapshot = {
10427 let mut registry = self.managed_worktrees.lock()
10428 .unwrap_or_else(|poisoned| poisoned.into_inner());
10429 let Some(lease) = registry.get_mut(lease_id) else { return };
10430 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10431 lease.cleanup_failure = Some(failure_kind);
10432 lease.updated_at_unix_ms = unix_time_ms();
10433 lease.snapshot()
10434 };
10435 let _ = self.persist_state();
10436 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10437 }
10438
10439 fn require_worktree_service(
10440 &self,
10441 source_workspace_id: &WorkspaceId,
10442 ) -> Result<(), NodeFailure> {
10443 if self.managed_worktrees.lock()
10444 .unwrap_or_else(|poisoned| poisoned.into_inner())
10445 .lease_for_workspace(source_workspace_id)
10446 .is_some()
10447 {
10448 return Err(failure(
10449 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10450 "managed worktree targets are internal-only workspaces",
10451 ));
10452 }
10453 match self
10454 .worktree_service_modes
10455 .get(source_workspace_id)
10456 .copied()
10457 .unwrap_or(WorktreeServiceMode::Manual)
10458 {
10459 WorktreeServiceMode::Manual => Ok(()),
10460 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10461 NodeFailureCode::UnsupportedSpawnCapability,
10462 "Git worktree service mode is unavailable in this node build",
10463 )),
10464 }
10465 }
10466
10467 fn managed_reservation_conflict(
10468 &self,
10469 workspace_id: Option<&WorkspaceId>,
10470 root: Option<&str>,
10471 ) -> bool {
10472 self.managed_worktrees.lock()
10473 .unwrap_or_else(|poisoned| poisoned.into_inner())
10474 .active_records()
10475 .any(|lease| {
10476 workspace_id.is_some_and(|workspace_id| {
10477 workspace_id == &lease.source_workspace_id
10478 || workspace_id == &lease.workspace_id
10479 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10480 })
10481 }
10482
10483 fn reject_managed_reservation(
10484 &self,
10485 workspace_id: Option<&WorkspaceId>,
10486 root: Option<&str>,
10487 ) -> Result<(), NodeFailure> {
10488 if self.managed_reservation_conflict(workspace_id, root) {
10489 Err(failure(
10490 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10491 "managed worktree ownership may only be mutated through its lease cleanup",
10492 ))
10493 } else {
10494 Ok(())
10495 }
10496 }
10497
10498 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10499 self.validate_address(address)?;
10500 self.workspace_root(&address.workspace_id)
10501 }
10502
10503 fn require_session_runtime_policy(
10537 &self,
10538 address: &SessionAddress,
10539 requirement: ProviderRuntimeRequirement,
10540 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10541 let runtime_policy = {
10542 let bindings = self
10543 .session_bindings
10544 .lock()
10545 .unwrap_or_else(|poisoned| poisoned.into_inner());
10546 let binding = bindings
10547 .get(&address.session.instance_id)
10548 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10549 if binding.workspace_id != address.workspace_id {
10550 return Err(failure(
10551 NodeFailureCode::SessionWorkspaceMismatch,
10552 "session belongs to another workspace",
10553 ));
10554 }
10555 if binding.generation != address.session.generation {
10556 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10557 }
10558 binding.runtime_policy
10559 };
10560 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10561 let snapshot = self.internal_session_snapshot(address)?;
10562 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10563 && snapshot.provider.lead_activity != ProviderActivity::Idle
10564 {
10565 return Err(failure(
10566 NodeFailureCode::TurnInFlight,
10567 "session already has a turn in flight; wait for it to complete before sending another prompt",
10568 ));
10569 }
10570 }
10571 require_policy(runtime_policy, false, false, requirement)
10572 .map(|()| runtime_policy)
10573 .map_err(|_| failure(
10574 NodeFailureCode::UnsupportedCapability,
10575 "session runtime policy does not admit this semantic operation",
10576 ))
10577 }
10578
10579 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10580 let mut bindings = self
10581 .session_bindings
10582 .lock()
10583 .unwrap_or_else(|poisoned| poisoned.into_inner());
10584 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10585 binding.workspace_id == address.workspace_id
10586 && binding.generation == address.session.generation
10587 }) {
10588 let removed = bindings.remove(&address.session.instance_id);
10589 self.clear_terminal_frame_watermark(address);
10590 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10591 return removed;
10592 }
10593 None
10594 }
10595
10596 fn cleanup_session_owned_materialization(
10597 &self,
10598 address: &SessionAddress,
10599 binding: &SessionBinding,
10600 ) -> Result<(), NodeFailure> {
10601 let Some(id) = binding.materialization_id.as_ref() else {
10602 return Ok(());
10603 };
10604 let owner = self
10605 .materializations
10606 .lock()
10607 .unwrap_or_else(|poisoned| poisoned.into_inner())
10608 .get(id)
10609 .map(|record| record.owner().clone());
10610 match owner {
10611 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10612 Some(MaterializationOwner::Session {
10613 incarnation_id,
10614 instance_id,
10615 generation,
10616 }) if incarnation_id == self.incarnation_id
10617 && instance_id == address.session.instance_id
10618 && generation == address.session.generation =>
10619 {
10620 self.cleanup_materialization(id)
10621 }
10622 Some(MaterializationOwner::Session { .. }) => Err(failure(
10623 NodeFailureCode::BackendOperationFailed,
10624 "session environment ownership requires recovery",
10625 )),
10626 }
10627 }
10628
10629 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10630 let mut watermarks = self
10631 .terminal_frame_watermarks
10632 .lock()
10633 .unwrap_or_else(|poisoned| poisoned.into_inner());
10634 if watermarks
10635 .get(&address.session.instance_id)
10636 .is_some_and(|(current, _)| current == address)
10637 {
10638 watermarks.remove(&address.session.instance_id);
10639 }
10640 }
10641
10642 #[cfg(test)]
10643 fn address_for(
10644 &self,
10645 instance_id: AgentInstanceId,
10646 generation: SessionGeneration,
10647 ) -> Option<SessionAddress> {
10648 let binding = self.session_bindings
10649 .lock()
10650 .unwrap_or_else(|poisoned| poisoned.into_inner())
10651 .get(&instance_id)
10652 .cloned()?;
10653 (binding.generation == generation).then_some(SessionAddress {
10654 workspace_id: binding.workspace_id,
10655 session: SessionKey { instance_id, generation },
10656 })
10657 }
10658
10659 fn arm_resume(
10660 &self,
10661 address: &SessionAddress,
10662 command_id: CommandId,
10663 runtime_policy: ProviderRuntimePolicy,
10664 ) -> Result<(), NodeFailure> {
10665 let mut bindings = self
10666 .session_bindings
10667 .lock()
10668 .unwrap_or_else(|poisoned| poisoned.into_inner());
10669 let binding = bindings
10670 .get_mut(&address.session.instance_id)
10671 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10672 if binding.workspace_id != address.workspace_id {
10673 return Err(failure(
10674 NodeFailureCode::SessionWorkspaceMismatch,
10675 "session belongs to another workspace",
10676 ));
10677 }
10678 if binding.generation != address.session.generation {
10679 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10680 }
10681 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10682 Ok(())
10683 }
10684
10685 fn clear_armed_resume(&self, address: &SessionAddress) {
10686 let mut bindings = self
10687 .session_bindings
10688 .lock()
10689 .unwrap_or_else(|poisoned| poisoned.into_inner());
10690 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10691 if binding.workspace_id == address.workspace_id
10692 && binding.generation == address.session.generation
10693 {
10694 binding.pending_resume = None;
10695 }
10696 }
10697 }
10698
10699 fn publish_control(&self, event: ControlEvent) {
10700 let revokes_harness_mcp = matches!(
10701 &event.event,
10702 ControlEventKind::Exited { .. }
10703 | ControlEventKind::Failed { .. }
10704 | ControlEventKind::Removed
10705 );
10706 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10707 let mut bindings = self
10708 .session_bindings
10709 .lock()
10710 .unwrap_or_else(|poisoned| poisoned.into_inner());
10711 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10712 return;
10713 };
10714 let previous_address = SessionAddress {
10715 workspace_id: binding.workspace_id.clone(),
10716 session: SessionKey {
10717 instance_id: event.instance_id,
10718 generation: binding.generation,
10719 },
10720 };
10721 let mut resume_command_rejected = false;
10722 if binding.generation == event.generation {
10723 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10724 let rejected_resume = matches!(
10725 &event.event,
10726 ControlEventKind::CommandRejected { .. }
10727 ) && event.command_id == Some(pending_command_id);
10728 let resume_failed = matches!(
10729 &event.event,
10730 ControlEventKind::ResumeDenied { .. }
10731 | ControlEventKind::ResumeFailed { .. }
10732 );
10733 if pending_generation == binding.generation
10734 && (rejected_resume || resume_failed)
10735 {
10736 resume_command_rejected = rejected_resume;
10737 binding.pending_resume = None;
10738 }
10739 }
10740 } else {
10741 let expected = binding.generation.0.checked_add(1);
10742 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10743 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10744 generation == binding.generation
10745 })
10746 || expected != Some(event.generation.0)
10747 || !authorized
10748 {
10749 return;
10750 }
10751 let (_, _, runtime_policy) = binding
10752 .pending_resume
10753 .expect("authorized resume retains its pending runtime policy");
10754 binding.generation = event.generation;
10755 binding.runtime_policy = runtime_policy;
10756 binding.pending_resume = None;
10757 }
10758 (
10759 SessionAddress {
10760 workspace_id: binding.workspace_id.clone(),
10761 session: SessionKey {
10762 instance_id: event.instance_id,
10763 generation: event.generation,
10764 },
10765 },
10766 (previous_address.session.generation != event.generation)
10767 .then_some(previous_address),
10768 binding.record_id.clone(),
10769 binding.runtime_policy.provider_session_identity,
10770 resume_command_rejected,
10771 )
10772 };
10773 if let Some(previous_address) = previous_address.as_ref() {
10774 self.clear_terminal_frame_watermark(previous_address);
10775 }
10776 if let Some(record_id) = record_id {
10777 self.reconcile_managed_record(
10778 &record_id,
10779 &address,
10780 &event,
10781 identity_admitted,
10782 resume_command_rejected,
10783 );
10784 }
10785 match &event.event {
10793 ControlEventKind::Running { process_id } => {
10794 #[cfg(unix)]
10795 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10796 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10797 }
10798 tracing::info!(
10799 node_id = %self.node_id,
10800 workspace_id = %address.workspace_id,
10801 session = ?address.session,
10802 pid = ?process_id,
10803 "session process is running",
10804 );
10805 }
10806 ControlEventKind::Exited { exit_code, forced } => {
10807 #[cfg(unix)]
10808 if let Some(proxy) = self.signer_proxy.get().cloned() {
10809 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10810 if proxy.has_session(&key) {
10811 std::thread::spawn(move || proxy.release(&key));
10813 }
10814 }
10815 tracing::info!(
10816 node_id = %self.node_id,
10817 workspace_id = %address.workspace_id,
10818 session = ?address.session,
10819 exit_code = ?exit_code,
10820 forced = forced,
10821 "session process exited",
10822 );
10823 }
10824 ControlEventKind::Failed { message } => {
10825 tracing::warn!(
10826 node_id = %self.node_id,
10827 workspace_id = %address.workspace_id,
10828 session = ?address.session,
10829 cause = %message,
10830 "session failed",
10831 );
10832 }
10833 ControlEventKind::CommandRejected { message } => {
10834 tracing::warn!(
10835 node_id = %self.node_id,
10836 workspace_id = %address.workspace_id,
10837 session = ?address.session,
10838 cause = %message,
10839 "session command rejected",
10840 );
10841 }
10842 ControlEventKind::Removed => {
10843 tracing::info!(
10844 node_id = %self.node_id,
10845 workspace_id = %address.workspace_id,
10846 session = ?address.session,
10847 "session removed",
10848 );
10849 }
10850 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10851 tracing::info!(
10852 node_id = %self.node_id,
10853 workspace_id = %address.workspace_id,
10854 session = ?address.session,
10855 provider_session = ?provider_session,
10856 "session resume authorized",
10857 );
10858 }
10859 ControlEventKind::Resumed { session: provider_session, process_id } => {
10860 tracing::info!(
10861 node_id = %self.node_id,
10862 workspace_id = %address.workspace_id,
10863 session = ?address.session,
10864 provider_session = ?provider_session,
10865 pid = ?process_id,
10866 "session resumed",
10867 );
10868 }
10869 ControlEventKind::ResumeDenied { reason } => {
10870 tracing::warn!(
10871 node_id = %self.node_id,
10872 workspace_id = %address.workspace_id,
10873 session = ?address.session,
10874 cause = %reason,
10875 "session resume denied",
10876 );
10877 }
10878 ControlEventKind::ResumeFailed { message } => {
10879 tracing::warn!(
10880 node_id = %self.node_id,
10881 workspace_id = %address.workspace_id,
10882 session = ?address.session,
10883 cause = %message,
10884 "session resume failed",
10885 );
10886 }
10887 _ => {}
10888 }
10889 self.apply_pending_acp_options(&address, &event);
10890 self.apply_acp_auto_permit(&address, &event);
10891 let stream_chunk = agent_stream_chunk(&event);
10892 self.publish(NodeEvent::Control {
10893 address: address.clone(),
10894 event,
10895 });
10896 if let Some(chunk) = stream_chunk {
10897 self.publish_agent_stream_chunk(address.clone(), chunk);
10898 }
10899 if revokes_harness_mcp {
10900 SessionLifecycleHook::on_session_ended(self, &address);
10901 }
10902 }
10903
10904 fn reconcile_managed_record(
10905 &self,
10906 record_id: &SessionRecordId,
10907 address: &SessionAddress,
10908 event: &ControlEvent,
10909 identity_admitted: bool,
10910 resume_command_rejected: bool,
10911 ) {
10912 let _transaction = self
10913 .state_transaction
10914 .lock()
10915 .unwrap_or_else(|poisoned| poisoned.into_inner());
10916 let observed_identity = match &event.event {
10917 ControlEventKind::ProviderEvent {
10918 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10919 ..
10920 } if identity_admitted => Some(identity.clone()),
10921 _ => None,
10922 };
10923 let identity_was_observed = observed_identity.is_some();
10924 let replacement_id = observed_identity
10925 .as_ref()
10926 .and_then(|_| self.allocate_record_id().ok());
10927 let mut upserts = Vec::new();
10928 let mut removals = Vec::new();
10929 let mut rebind = None;
10930 let original_records;
10931 let original_materializations = self
10932 .materializations
10933 .lock()
10934 .unwrap_or_else(|poisoned| poisoned.into_inner())
10935 .clone();
10936 {
10937 let mut records = self
10938 .session_records
10939 .lock()
10940 .unwrap_or_else(|poisoned| poisoned.into_inner());
10941 original_records = records.records.clone();
10942 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10943 return;
10944 };
10945 if let Some(identity) = observed_identity {
10946 let existing_id = records
10947 .records
10948 .values()
10949 .find(|record| {
10950 record.record_id != *record_id
10951 && record.provider_session.as_ref().is_some_and(|record_identity| {
10952 session_registry::same_provider_session(
10953 &record.provider,
10954 record_identity,
10955 ¤t_snapshot.provider,
10956 &identity,
10957 )
10958 })
10959 })
10960 .map(|record| record.record_id.clone());
10961 if let Some(existing_id) = existing_id {
10962 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10963 record.mode == current_snapshot.mode
10964 && record.workspace_id == current_snapshot.workspace_id
10965 && record.environment_profile == current_snapshot.environment_profile
10966 && record.bundle == current_snapshot.bundle
10967 && record.context_id == current_snapshot.context_id
10968 && record.context == current_snapshot.context
10969 && record
10970 .canonical_root
10971 .as_utf8()
10972 .zip(current_snapshot.canonical_root.as_utf8())
10973 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10974 });
10975 if !scope_matches {
10976 if let Some(current) = records.records.get_mut(record_id) {
10977 current.state = ManagedSessionState::Unavailable;
10978 current.last_error =
10979 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10980 current.updated_at_unix_ms = unix_time_ms();
10981 upserts.push(current.clone());
10982 }
10983 } else {
10984 let existing_is_active = records
10985 .records
10986 .get(&existing_id)
10987 .is_some_and(|record| record.active_session.is_some());
10988 if existing_is_active {
10989 if let Some(current) = records.records.get_mut(record_id) {
10990 current.state = ManagedSessionState::Unavailable;
10991 current.last_error =
10992 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10993 current.updated_at_unix_ms = unix_time_ms();
10994 upserts.push(current.clone());
10995 }
10996 } else {
10997 if current_snapshot.provider_session.is_some() {
10998 if let Some(current) = records.records.get_mut(record_id) {
10999 current.active_session = None;
11000 current.state = ManagedSessionState::Dormant;
11001 current.updated_at_unix_ms = unix_time_ms();
11002 upserts.push(current.clone());
11003 }
11004 } else {
11005 records.records.remove(record_id);
11006 removals.push(record_id.clone());
11007 }
11008 if let Some(existing) = records.records.get_mut(&existing_id) {
11009 existing.provider_session = Some(identity.clone());
11010 existing.active_session = Some(address.clone());
11011 existing.state = ManagedSessionState::Live;
11012 existing.updated_at_unix_ms = unix_time_ms();
11013 existing.last_error = None;
11014 upserts.push(existing.clone());
11015 }
11016 rebind = Some(existing_id);
11017 }
11018 }
11019 } else if current_snapshot
11020 .provider_session
11021 .as_ref()
11022 .is_some_and(|current| {
11023 !session_registry::same_provider_session(
11024 ¤t_snapshot.provider,
11025 current,
11026 ¤t_snapshot.provider,
11027 &identity,
11028 )
11029 })
11030 {
11031 if let Some(current) = records.records.get_mut(record_id) {
11032 current.active_session = None;
11033 if current.bundle.is_some() || current.context.is_some() {
11034 let had_bundle = current.bundle.is_some();
11035 current.provider_session = None;
11036 current.bundle = None;
11037 current.context_id = None;
11038 current.context = None;
11039 current.state = ManagedSessionState::Unavailable;
11040 current.last_error = Some(if had_bundle {
11041 "bundle-unavailable".to_owned()
11042 } else {
11043 "context-unavailable".to_owned()
11044 });
11045 } else {
11046 current.state = ManagedSessionState::Dormant;
11047 }
11048 current.updated_at_unix_ms = unix_time_ms();
11049 upserts.push(current.clone());
11050 }
11051 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11052 if let Some(current) = records.records.get_mut(record_id) {
11053 current.state = ManagedSessionState::Unavailable;
11054 current.last_error =
11055 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11056 current.updated_at_unix_ms = unix_time_ms();
11057 upserts.push(current.clone());
11058 }
11059 } else if let Some(new_record_id) = replacement_id {
11060 let now = unix_time_ms();
11061 let replacement = ManagedSessionRecord {
11062 record_id: new_record_id.clone(),
11063 display_name: format!(
11064 "{} #{}",
11065 current_snapshot.provider.as_str(),
11066 records
11067 .records
11068 .values()
11069 .filter(|record| {
11070 record.provider == current_snapshot.provider
11071 })
11072 .count()
11073 .saturating_add(1),
11074 ),
11075 provider: current_snapshot.provider.clone(),
11076 mode: current_snapshot.mode,
11077 state: ManagedSessionState::Live,
11078 workspace_id: current_snapshot.workspace_id,
11079 canonical_root: current_snapshot.canonical_root,
11080 provider_session: Some(identity),
11081 active_session: Some(address.clone()),
11082 environment_profile: current_snapshot.environment_profile,
11083 bundle: current_snapshot.bundle,
11084 context_id: current_snapshot.context_id,
11085 context: current_snapshot.context,
11086 exported_context: None,
11092 task_binding: current_snapshot.task_binding,
11093 created_at_unix_ms: now,
11094 updated_at_unix_ms: now,
11095 last_error: None,
11096 };
11097 records
11098 .records
11099 .insert(new_record_id.clone(), replacement.clone());
11100 upserts.push(replacement);
11101 rebind = Some(new_record_id);
11102 } else if let Some(current) = records.records.get_mut(record_id) {
11103 current.state = ManagedSessionState::Unavailable;
11104 current.last_error =
11105 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11106 upserts.push(current.clone());
11107 }
11108 } else if let Some(current) = records.records.get_mut(record_id) {
11109 current.provider_session = Some(identity);
11110 current.active_session = Some(address.clone());
11111 current.state = ManagedSessionState::Live;
11112 current.updated_at_unix_ms = unix_time_ms();
11113 current.last_error = None;
11114 upserts.push(current.clone());
11115 }
11116 } else if let Some(current) = records.records.get_mut(record_id) {
11117 if resume_command_rejected {
11118 current.active_session = None;
11119 current.state = detached_record_state(current);
11120 current.updated_at_unix_ms = unix_time_ms();
11121 current.last_error =
11122 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11123 upserts.push(current.clone());
11124 } else {
11125 match &event.event {
11126 ControlEventKind::Running { .. }
11127 | ControlEventKind::ResumeAuthorized { .. }
11128 | ControlEventKind::Resumed { .. } => {
11129 current.active_session = Some(address.clone());
11130 current.state = active_record_state(
11131 identity_admitted,
11132 current.provider_session.is_some(),
11133 );
11134 current.updated_at_unix_ms = unix_time_ms();
11135 current.last_error = None;
11136 upserts.push(current.clone());
11137 }
11138 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11139 current.active_session = None;
11140 current.state = detached_record_state(current);
11141 current.updated_at_unix_ms = unix_time_ms();
11142 upserts.push(current.clone());
11143 }
11144 ControlEventKind::Failed { message }
11145 | ControlEventKind::ResumeFailed { message } => {
11146 current.active_session = None;
11147 current.state = detached_record_state(current);
11148 current.updated_at_unix_ms = unix_time_ms();
11149 current.last_error = Some(
11150 session_registry::sanitized_record_error_summary(message),
11151 );
11152 upserts.push(current.clone());
11153 }
11154 ControlEventKind::ResumeDenied { reason } => {
11155 current.active_session = None;
11156 current.state = detached_record_state(current);
11157 current.updated_at_unix_ms = unix_time_ms();
11158 current.last_error = Some(
11159 session_registry::sanitized_record_error_summary(reason),
11160 );
11161 upserts.push(current.clone());
11162 }
11163 _ => {}
11164 }
11165 }
11166 }
11167 }
11168 let materialization_owner_record = identity_was_observed.then(|| {
11169 rebind.clone().unwrap_or_else(|| record_id.clone())
11170 }).and_then(|candidate| {
11171 self.session_records
11172 .lock()
11173 .unwrap_or_else(|poisoned| poisoned.into_inner())
11174 .records
11175 .get(&candidate)
11176 .filter(|record| {
11177 record.provider_session.is_some()
11178 && record.active_session.as_ref() == Some(address)
11179 })
11180 .map(|_| candidate)
11181 });
11182 if let Some(owner_record_id) = materialization_owner_record {
11183 let materialization_id = self
11184 .session_bindings
11185 .lock()
11186 .unwrap_or_else(|poisoned| poisoned.into_inner())
11187 .get(&address.session.instance_id)
11188 .and_then(|binding| binding.materialization_id.clone());
11189 if let Some(materialization_id) = materialization_id {
11190 let mut transfer_failed = false;
11191 {
11192 let mut materializations = self
11193 .materializations
11194 .lock()
11195 .unwrap_or_else(|poisoned| poisoned.into_inner());
11196 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11197 match ownership.owner() {
11198 MaterializationOwner::Session {
11199 incarnation_id,
11200 instance_id,
11201 generation,
11202 } if *incarnation_id == self.incarnation_id
11203 && *instance_id == address.session.instance_id
11204 && *generation == address.session.generation =>
11205 {
11206 transfer_failed = ownership
11207 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11208 .is_err();
11209 }
11210 MaterializationOwner::Record { record_id }
11211 if record_id == &owner_record_id => {}
11212 MaterializationOwner::Record { record_id } => {
11213 let expected = record_id.clone();
11214 transfer_failed = ownership
11215 .transfer_record_owner(
11216 &expected,
11217 owner_record_id.clone(),
11218 unix_time_ms(),
11219 )
11220 .is_err();
11221 }
11222 _ => transfer_failed = true,
11223 }
11224 if transfer_failed {
11225 let _ = ownership.mark_recovery_required(unix_time_ms());
11226 }
11227 } else {
11228 transfer_failed = true;
11229 }
11230 }
11231 if transfer_failed {
11232 if let Some(record) = self
11233 .session_records
11234 .lock()
11235 .unwrap_or_else(|poisoned| poisoned.into_inner())
11236 .records
11237 .get_mut(&owner_record_id)
11238 {
11239 record.state = ManagedSessionState::Unavailable;
11240 record.last_error = Some("environment-profile-unavailable".to_owned());
11241 record.updated_at_unix_ms = unix_time_ms();
11242 upserts.push(record.clone());
11243 }
11244 }
11245 }
11246 }
11247 if let Some(new_record_id) = rebind.clone() {
11248 if let Some(binding) = self
11249 .session_bindings
11250 .lock()
11251 .unwrap_or_else(|poisoned| poisoned.into_inner())
11252 .get_mut(&address.session.instance_id)
11253 {
11254 binding.record_id = Some(new_record_id);
11255 }
11256 }
11257 if upserts.is_empty() && removals.is_empty() {
11258 return;
11259 }
11260 if self.persist_state_locked().is_err() {
11261 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11262 let mut records = self
11263 .session_records
11264 .lock()
11265 .unwrap_or_else(|poisoned| poisoned.into_inner());
11266 records.records = original_records;
11267 *self
11268 .materializations
11269 .lock()
11270 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11271 if let Some(current) = records.records.get_mut(record_id) {
11272 current.state = ManagedSessionState::Unavailable;
11273 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11274 current.updated_at_unix_ms = unix_time_ms();
11275 upserts = vec![current.clone()];
11276 } else {
11277 upserts.clear();
11278 }
11279 removals.clear();
11280 drop(records);
11281 if let Some(binding) = self
11282 .session_bindings
11283 .lock()
11284 .unwrap_or_else(|poisoned| poisoned.into_inner())
11285 .get_mut(&address.session.instance_id)
11286 {
11287 binding.record_id = Some(record_id.clone());
11288 }
11289 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11290 }
11291 for record in upserts {
11292 self.publish_record(record);
11293 }
11294 for record_id in removals {
11295 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11296 }
11297 }
11298
11299 fn snapshot(&self) -> NodeSnapshot {
11300 let control = self.handle.snapshot();
11301 let bindings = self
11302 .session_bindings
11303 .lock()
11304 .unwrap_or_else(|poisoned| poisoned.into_inner())
11305 .clone();
11306 let workspace_roots = self
11307 .workspaces
11308 .read()
11309 .unwrap_or_else(|poisoned| poisoned.into_inner())
11310 .clone();
11311 let environment_profiles = self
11312 .environment_profiles
11313 .read()
11314 .unwrap_or_else(|poisoned| poisoned.into_inner());
11315 let mut workspaces = workspace_roots
11316 .iter()
11317 .map(|(workspace_id, canonical_root)| {
11318 (
11319 workspace_id.clone(),
11320 WorkspaceSnapshot {
11321 workspace_id: workspace_id.clone(),
11322 canonical_root: opaque_windows_path(canonical_root.clone()),
11323 sessions: Vec::new(),
11324 worktree_service_mode: Some(
11325 self.worktree_service_modes
11326 .get(workspace_id)
11327 .copied()
11328 .unwrap_or(WorktreeServiceMode::Manual),
11329 ),
11330 managed_worktree_profiles: Some(WorktreeProfileInventory {
11331 profiles: self
11332 .managed_worktree_profiles
11333 .get(workspace_id)
11334 .into_iter()
11335 .flat_map(BTreeMap::values)
11336 .map(|profile| ManagedWorktreeProfileSummary {
11337 id: profile.profile_id().clone(),
11338 revision: profile.revision().clone(),
11339 retention: profile.retention(),
11340 })
11341 .collect(),
11342 }),
11343 },
11344 )
11345 })
11346 .collect::<BTreeMap<_, _>>();
11347 let mut agent_progress = Vec::new();
11348 for session in &control.sessions {
11349 let Some(binding) = bindings.get(&session.instance_id) else {
11350 continue;
11351 };
11352 if binding.generation != session.generation {
11353 continue;
11354 }
11355 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11356 let address = SessionAddress {
11357 workspace_id: binding.workspace_id.clone(),
11358 session: SessionKey {
11359 instance_id: session.instance_id,
11360 generation: session.generation,
11361 },
11362 };
11363 if let Some(progress) =
11364 agent_progress_from_provider_snapshot(address, &session.provider)
11365 {
11366 agent_progress.push(progress);
11367 }
11368 workspace.sessions.push(session.clone());
11369 }
11370 }
11371 NodeSnapshot {
11372 node_id: self.node_id.clone(),
11373 enabled_providers: self.enabled_providers.clone(),
11374 provider_runtime_statuses: {
11375 let updates = self
11376 .provider_runtime_status_updates
11377 .read()
11378 .unwrap_or_else(|poisoned| poisoned.into_inner());
11379 ProviderRuntimeStatuses::new(
11380 self.provider_runtime_statuses
11381 .iter()
11382 .filter(|status| !updates.contains_key(status.provider()))
11383 .cloned()
11384 .chain(updates.values().cloned()),
11385 )
11386 .expect("refreshed provider runtime inventory remains bounded and unique")
11387 },
11388 workspaces: workspaces.into_values().collect(),
11389 session_records: self
11390 .session_records
11391 .lock()
11392 .unwrap_or_else(|poisoned| poisoned.into_inner())
11393 .records
11394 .values()
11395 .cloned()
11396 .collect(),
11397 managed_worktrees: self.managed_worktrees
11398 .lock()
11399 .unwrap_or_else(|poisoned| poisoned.into_inner())
11400 .snapshots(),
11401 launch_inventory: Some(LaunchInventory {
11402 spawn_profiles: Some(
11403 self.spawn_profiles
11404 .iter()
11405 .filter_map(|profile| {
11406 resolved_spawn_profile_summary(profile, &environment_profiles)
11407 })
11408 .collect(),
11409 ),
11410 bundles: Some(
11411 self.bundle_catalog
11412 .read()
11413 .unwrap_or_else(|poisoned| poisoned.into_inner())
11414 .iter()
11415 .map(NodeBundle::receipt)
11416 .collect(),
11417 ),
11418 network_allowlists: {
11421 let catalog = self
11422 .network_allowlist_catalog
11423 .read()
11424 .unwrap_or_else(|poisoned| poisoned.into_inner());
11425 if catalog.is_empty() {
11426 None
11427 } else {
11428 Some(catalog.ids().cloned().collect())
11429 }
11430 },
11431 }),
11432 agent_progress,
11433 }
11434 }
11435
11436 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11437 assert!(
11438 !matches!(
11439 &event,
11440 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11441 ),
11442 "terminal frame and agent stream events must use their own dedicated live channel",
11443 );
11444 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11445 let sequence = history
11446 .last_sequence
11447 .checked_add(1)
11448 .expect("node event sequence exhausted");
11449 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11450 if let Some(evicted) = history.events.pop_front() {
11451 history.replay_floor_sequence = evicted
11452 .sequence
11453 .checked_add(1)
11454 .expect("node replay floor sequence exhausted");
11455 history.removed_record_providers.remove(&evicted.sequence);
11456 }
11457 }
11458 match &event {
11459 NodeEvent::HarnessMcpReadCall { .. } => {
11460 panic!("transient harness MCP calls must not enter durable event history")
11461 }
11462 NodeEvent::SessionRecordUpserted { record } => {
11463 history
11464 .record_providers
11465 .insert(record.record_id.clone(), record.provider.clone());
11466 }
11467 NodeEvent::SessionRecordRemoved { record_id } => {
11468 if let Some(provider) = history.record_providers.remove(record_id) {
11469 history.removed_record_providers.insert(sequence, provider);
11470 }
11471 }
11472 NodeEvent::ManagedWorktreeUpserted { .. }
11473 | NodeEvent::ManagedWorktreeRemoved { .. }
11474 | NodeEvent::Control { .. }
11475 | NodeEvent::ControllerChanged { .. }
11476 | NodeEvent::SessionRecordHistorySummarized { .. }
11477 | NodeEvent::WorkspaceAdded { .. }
11478 | NodeEvent::WorkspaceRemoved { .. }
11479 | NodeEvent::ResyncRequired { .. }
11480 | NodeEvent::AgentStream { .. }
11481 | NodeEvent::TerminalFrame { .. } => {}
11482 }
11483 let envelope = NodeEventEnvelope { sequence, event };
11484 history.events.push_back(envelope.clone());
11485 history.last_sequence = sequence;
11486 let _ = self.event_tx.send(envelope.clone());
11487 drop(history);
11488 envelope
11489 }
11490
11491 fn publish_transient(&self, event: NodeEvent) -> bool {
11502 assert!(event.requires_harness_mcp_proxy_capability());
11503 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11504 let envelope = NodeEventEnvelope { sequence: 0, event };
11505 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11506 Ok(_) => true,
11507 Err(broadcast::error::SendError(undelivered)) => {
11508 if let Some(NodeEventEnvelope {
11509 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11510 ..
11511 }) = undelivered.first() {
11512 tracing::warn!(
11518 call_id = call_id.as_str(),
11519 content_type = ?request.content_type,
11520 cause = "no-subscribers",
11521 "harness MCP proxy event could not be published",
11522 );
11523 }
11524 false
11525 }
11526 }
11527 }
11528
11529 fn publish_terminal_frames(&self) {
11530 let snapshot = self.handle.snapshot();
11531 let bindings = self
11532 .session_bindings
11533 .lock()
11534 .unwrap_or_else(|poisoned| poisoned.into_inner());
11535 let candidates = snapshot
11536 .sessions
11537 .iter()
11538 .filter_map(|session| {
11539 let binding = bindings.get(&session.instance_id)?;
11540 if binding.generation != session.generation {
11541 return None;
11542 }
11543 let frame = session.terminal_frame.clone()?;
11544 crate::session_signals::feed(
11545 binding.workspace_id.as_str(),
11546 session.instance_id.0,
11547 session.agent_id.as_str(),
11548 &frame.contents,
11549 );
11550 Some((
11551 SessionAddress {
11552 workspace_id: binding.workspace_id.clone(),
11553 session: SessionKey {
11554 instance_id: session.instance_id,
11555 generation: session.generation,
11556 },
11557 },
11558 frame,
11559 ))
11560 })
11561 .collect();
11562 drop(bindings);
11563 self.publish_terminal_frame_candidates(candidates);
11564 }
11565
11566 fn publish_terminal_frame_candidates(
11567 &self,
11568 candidates: Vec<(SessionAddress, TerminalFrame)>,
11569 ) {
11570 let mut watermarks = self
11571 .terminal_frame_watermarks
11572 .lock()
11573 .unwrap_or_else(|poisoned| poisoned.into_inner());
11574 let mut events = Vec::with_capacity(candidates.len());
11575 for (address, frame) in candidates {
11576 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11577 let advanced = watermarks
11578 .get(&address.session.instance_id)
11579 .map_or(true, |(current_address, sequence)| {
11580 current_address != &address || frame.sequence > *sequence
11581 });
11582 if !advanced {
11583 continue;
11584 }
11585 if is_first_frame {
11586 if self.controller_state().is_none() {
11600 tracing::warn!(
11601 node_id = %self.node_id,
11602 workspace_id = %address.workspace_id,
11603 session = ?address.session,
11604 terminal_size = ?frame.size,
11605 "session producing output with no controller attached",
11606 );
11607 } else {
11608 tracing::info!(
11609 node_id = %self.node_id,
11610 workspace_id = %address.workspace_id,
11611 session = ?address.session,
11612 terminal_size = ?frame.size,
11613 "session produced its first output",
11614 );
11615 }
11616 }
11617 watermarks.insert(
11618 address.session.instance_id,
11619 (address.clone(), frame.sequence),
11620 );
11621 if let Some((at, reported)) = self
11622 .pty_input_marks
11623 .lock()
11624 .unwrap_or_else(|poisoned| poisoned.into_inner())
11625 .get_mut(&address.session.instance_id)
11626 {
11627 if !*reported && at.elapsed() < Duration::from_secs(5) {
11629 *reported = true;
11630 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11631 }
11632 }
11633 events.push(NodeEvent::TerminalFrame { address, frame });
11634 }
11635 drop(watermarks);
11636 if events.is_empty() {
11637 return;
11638 }
11639 let mut history = self
11640 .history
11641 .lock()
11642 .unwrap_or_else(|poisoned| poisoned.into_inner());
11643 let mut envelopes = Vec::with_capacity(events.len());
11644 for event in events {
11645 let sequence = history
11646 .last_sequence
11647 .checked_add(1)
11648 .expect("node event sequence exhausted");
11649 history.last_sequence = sequence;
11650 envelopes.push(NodeEventEnvelope { sequence, event });
11651 }
11652 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11653 drop(history);
11654 }
11655
11656 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11665 let mut history = self
11666 .history
11667 .lock()
11668 .unwrap_or_else(|poisoned| poisoned.into_inner());
11669 let sequence = history
11670 .last_sequence
11671 .checked_add(1)
11672 .expect("node event sequence exhausted");
11673 history.last_sequence = sequence;
11674 drop(history);
11675 tracing::debug!(
11683 source_sequence = chunk.source_sequence,
11684 kind = agent_stream_chunk_kind_label(&chunk.kind),
11685 envelope_sequence = sequence,
11686 "publishing agent stream chunk",
11687 );
11688 let envelope = NodeEventEnvelope {
11689 sequence,
11690 event: NodeEvent::AgentStream { address, chunk },
11691 };
11692 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11693 }
11694
11695 fn current_sequence(&self) -> u64 {
11696 self.history
11697 .lock()
11698 .unwrap_or_else(|poisoned| poisoned.into_inner())
11699 .last_sequence
11700 }
11701
11702 fn controller_state(&self) -> Option<ControllerState> {
11703 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11704 let lease = controller.as_ref().copied()?;
11705 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11706 if remaining.is_zero() {
11707 *controller = None;
11708 return None;
11709 }
11710 Some(ControllerState {
11711 connection_id: lease.connection_id,
11712 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11713 })
11714 }
11715
11716 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11717 if role != ClientRole::Operator {
11718 tracing::warn!(
11719 node_id = %self.node_id,
11720 connection_id,
11721 "controller lease rejected: observer connections cannot acquire control",
11722 );
11723 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11724 }
11725 let lease_ms = if lease_ms == 0 {
11726 DEFAULT_CONTROLLER_LEASE_MS
11727 } else {
11728 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11729 };
11730 let now = Instant::now();
11731 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11732 if let Some(active) = controller.as_ref() {
11733 if active.connection_id != connection_id && active.expires_at > now {
11734 tracing::warn!(
11735 node_id = %self.node_id,
11736 connection_id,
11737 holder_connection_id = active.connection_id,
11738 "controller lease rejected: another operator holds it",
11739 );
11740 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11741 }
11742 }
11743 let is_new_attach = match controller.as_ref() {
11750 Some(active) => active.connection_id != connection_id,
11751 None => true,
11752 };
11753 *controller = Some(ControllerLease {
11754 connection_id,
11755 expires_at: now + Duration::from_millis(lease_ms),
11756 });
11757 drop(controller);
11758 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11759 if is_new_attach {
11760 tracing::info!(
11761 node_id = %self.node_id,
11762 connection_id,
11763 lease_ms,
11764 "controller lease acquired",
11765 );
11766 } else {
11767 tracing::debug!(
11768 node_id = %self.node_id,
11769 connection_id,
11770 lease_ms,
11771 "controller lease renewed",
11772 );
11773 }
11774 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11775 Ok(state)
11776 }
11777
11778 fn release_controller(&self, connection_id: u64) -> bool {
11779 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11780 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11781 if released {
11782 *controller = None;
11783 }
11784 drop(controller);
11785 if released {
11786 tracing::info!(
11787 node_id = %self.node_id,
11788 connection_id,
11789 "controller lease released",
11790 );
11791 self.publish(NodeEvent::ControllerChanged { controller: None });
11792 }
11793 released
11794 }
11795
11796 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11805 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11806 return;
11807 };
11808 let id = address.session.instance_id;
11809 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11810 return;
11811 };
11812 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11813 .iter()
11814 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11815 .collect();
11816 let (writes, set_model, effort_gap) =
11817 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11818 if set_model {
11819 if let Some(model_id) = model.clone() {
11820 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11821 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11822 }
11823 }
11824 }
11825 for (option_id, value_json) in writes {
11826 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11827 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11828 }
11829 }
11830 if effort_gap {
11831 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11832 }
11833 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11834 }
11835
11836 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11839 let id = address.session.instance_id;
11840 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11841 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11842 crate::auto_permit::forget_acp(id.0);
11843 return;
11844 }
11845 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11846 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11847 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11848 }
11849 match provider_event {
11850 ProviderEvent::ModeChanged { mode_id, available } => {
11851 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11852 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11853 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11854 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11855 }
11856 }
11857 }
11858 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11859 match self.dispatch(ControlCommand::ResolveInteraction {
11860 instance_id: id,
11861 generation: address.session.generation,
11862 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11863 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11864 }) {
11865 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11866 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11867 }
11868 }
11869 _ => {}
11872 }
11873 }
11874
11875 fn mark_spawn_provisional(&self, address: SessionAddress) {
11876 let id = address.session.instance_id;
11877 self.provisional_spawns
11878 .lock()
11879 .unwrap_or_else(|poisoned| poisoned.into_inner())
11880 .insert(id, (address, Instant::now()));
11881 tracing::info!(
11882 node_id = %self.node_id,
11883 instance_id = id.0,
11884 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11885 "draft spawn; waiting for HQ view",
11886 );
11887 }
11888
11889 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11890 let removed = self
11891 .provisional_spawns
11892 .lock()
11893 .unwrap_or_else(|poisoned| poisoned.into_inner())
11894 .remove(&instance_id);
11895 if removed.is_some() {
11896 tracing::info!(
11897 node_id = %self.node_id,
11898 instance_id = instance_id.0,
11899 "draft spawn claimed by HQ view",
11900 );
11901 }
11902 }
11903
11904 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11905 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11906 let mut map = self
11907 .provisional_spawns
11908 .lock()
11909 .unwrap_or_else(|poisoned| poisoned.into_inner());
11910 let due: Vec<AgentInstanceId> = map
11911 .iter()
11912 .filter(|(_, (_, at))| at.elapsed() >= grace)
11913 .map(|(id, _)| *id)
11914 .collect();
11915 due.into_iter()
11916 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11917 .collect()
11918 }
11919
11920 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11921 let instance_id = address.session.instance_id;
11922 tracing::warn!(
11923 node_id = %self.node_id,
11924 instance_id = instance_id.0,
11925 workspace = %address.workspace_id,
11926 reason = "attach_never_arrived",
11927 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11928 "draft spawn never viewed; stopping PTY",
11929 );
11930 let _ = self.dispatch(ControlCommand::Stop {
11931 instance_id,
11932 force: true,
11933 });
11934 let _ = self.remove_session(&address).await;
11935 }
11936
11937 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11938 if role != ClientRole::Operator {
11939 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11940 }
11941 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11942 let now = Instant::now();
11943 match controller.as_ref().copied() {
11944 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11945 Some(active) if active.expires_at <= now => {
11946 *controller = None;
11947 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11948 }
11949 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11950 }
11951 }
11952
11953 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11954 if !self
11955 .workspaces
11956 .read()
11957 .unwrap_or_else(|poisoned| poisoned.into_inner())
11958 .contains_key(&address.workspace_id)
11959 {
11960 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11961 }
11962 let binding = self
11963 .session_bindings
11964 .lock()
11965 .unwrap_or_else(|poisoned| poisoned.into_inner())
11966 .get(&address.session.instance_id)
11967 .cloned()
11968 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11969 if binding.workspace_id != address.workspace_id {
11970 return Err(failure(
11971 NodeFailureCode::SessionWorkspaceMismatch,
11972 "session belongs to another workspace",
11973 ));
11974 }
11975 if binding.generation != address.session.generation {
11976 return Err(failure(
11977 NodeFailureCode::StaleGeneration,
11978 &format!(
11979 "stale session generation: expected {}, received {}",
11980 binding.generation.0,
11981 address.session.generation.0,
11982 ),
11983 ));
11984 }
11985 let snapshot = self.handle.snapshot();
11986 let Some(current) = snapshot
11987 .sessions
11988 .iter()
11989 .find(|item| item.instance_id == address.session.instance_id)
11990 else {
11991 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11992 };
11993 if current.generation != address.session.generation {
11994 return Err(failure(
11995 NodeFailureCode::StaleGeneration,
11996 &format!(
11997 "stale session generation: expected {}, received {}",
11998 current.generation.0,
11999 address.session.generation.0,
12000 ),
12001 ));
12002 }
12003 Ok(current.agent_id.clone())
12004 }
12005
12006 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12007 self.dispatch_envelope(self.prepare_command(command))
12008 }
12009
12010 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12011 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12012 CommandEnvelope {
12013 id: CommandId(id),
12014 command,
12015 }
12016 }
12017
12018 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12019 let command_id = envelope.id;
12020 self.handle
12021 .dispatch(envelope)
12022 .map_err(|error| match error {
12023 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12024 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12025 })?;
12026 Ok(command_id)
12027 }
12028
12029 fn record_rejected_command(
12038 &self,
12039 command_id: CommandId,
12040 message: String,
12041 unsupported_transport: bool,
12042 ) {
12043 let now = Instant::now();
12044 let mut reasons = self.rejected_command_reasons.lock()
12045 .unwrap_or_else(|poisoned| poisoned.into_inner());
12046 reasons.retain(|_, reason| reason.expires_at > now);
12047 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12048 if let Some(&oldest) = reasons.keys().next() {
12049 reasons.remove(&oldest);
12050 }
12051 }
12052 reasons.insert(
12053 command_id,
12054 RejectedCommandReason {
12055 message,
12056 unsupported_transport,
12057 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12058 },
12059 );
12060 }
12061
12062 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12069 let now = Instant::now();
12070 let mut reasons = self.rejected_command_reasons.lock()
12071 .unwrap_or_else(|poisoned| poisoned.into_inner());
12072 match reasons.remove(&command_id) {
12073 Some(reason) if reason.expires_at > now => Some(reason),
12074 _ => None,
12075 }
12076 }
12077
12078 async fn dispatch_bounded(
12079 &self,
12080 command: ControlCommand,
12081 timeout_duration: Duration,
12082 ) -> Result<CommandId, NodeFailure> {
12083 let deadline = Instant::now() + timeout_duration;
12084 loop {
12085 match self.dispatch(command.clone()) {
12086 Ok(command_id) => return Ok(command_id),
12087 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12088 sleep(Duration::from_millis(2)).await;
12089 }
12090 Err(error) => return Err(error),
12091 }
12092 }
12093 }
12094
12095 fn internal_session_snapshot(
12096 &self,
12097 address: &SessionAddress,
12098 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12099 self.validate_address(address)?;
12100 self.handle
12101 .snapshot()
12102 .sessions
12103 .clone()
12104 .into_iter()
12105 .find(|session| {
12106 session.instance_id == address.session.instance_id
12107 && session.generation == address.session.generation
12108 })
12109 .ok_or_else(|| {
12110 failure(
12111 NodeFailureCode::UnknownSession,
12112 "session snapshot is unavailable",
12113 )
12114 })
12115 }
12116
12117 fn require_acp_session(
12125 &self,
12126 address: &SessionAddress,
12127 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12128 let snapshot = self.internal_session_snapshot(address)?;
12129 if snapshot.transport != TransportKind::Acp {
12130 return Err(failure(
12131 NodeFailureCode::UnsupportedTransport,
12132 "this verb requires a session spawned over the ACP transport",
12133 ));
12134 }
12135 Ok(snapshot)
12136 }
12137
12138 async fn dispatch_history_bounded(
12139 &self,
12140 address: &SessionAddress,
12141 command: ControlCommand,
12142 operation: HistoryOperation,
12143 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12144 self.validate_address(address)?;
12145 let started_after = self.current_sequence();
12146 let command_id = self
12147 .dispatch_bounded(
12148 command,
12149 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12150 )
12151 .await?;
12152 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12153 let mut scan_after = started_after;
12154 let mut accepted_after = None;
12155 loop {
12156 let events = self
12157 .history
12158 .lock()
12159 .unwrap_or_else(|poisoned| poisoned.into_inner())
12160 .events
12161 .iter()
12162 .filter(|envelope| envelope.sequence > scan_after)
12163 .cloned()
12164 .collect::<Vec<_>>();
12165 for envelope in events {
12166 scan_after = scan_after.max(envelope.sequence);
12167 let NodeEvent::Control {
12168 address: event_address,
12169 event,
12170 } = envelope.event
12171 else {
12172 continue;
12173 };
12174 if event_address != *address {
12175 continue;
12176 }
12177 if event.command_id == Some(command_id) {
12178 match &event.event {
12179 ControlEventKind::CommandRejected { .. } => {
12180 return Err(failure(
12181 NodeFailureCode::InvalidRequest,
12182 "history request was rejected",
12183 ));
12184 }
12185 ControlEventKind::HistoryRequested {
12186 operation: requested,
12187 ..
12188 } if requested == &operation => {
12189 accepted_after = Some(envelope.sequence);
12190 continue;
12191 }
12192 _ => {}
12193 }
12194 }
12195 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12196 match (&operation, &event.event) {
12197 (
12198 HistoryOperation::Discover { .. },
12199 ControlEventKind::HistoryDiscovered { .. },
12200 )
12201 | (
12202 HistoryOperation::Load { .. },
12203 ControlEventKind::HistoryLoaded { .. },
12204 ) => return self.internal_session_snapshot(address),
12205 (_, ControlEventKind::HistoryFailed { .. }) => {
12206 return Err(failure(
12207 NodeFailureCode::BackendOperationFailed,
12208 "history operation failed",
12209 ));
12210 }
12211 _ => {}
12212 }
12213 }
12214 }
12215 if Instant::now() >= deadline {
12216 return Err(failure(
12217 NodeFailureCode::BackendBusy,
12218 if accepted_after.is_some() {
12219 "history operation did not settle before the bounded deadline"
12220 } else {
12221 "history operation was not accepted before the bounded deadline"
12222 },
12223 ));
12224 }
12225 sleep(Duration::from_millis(2)).await;
12226 }
12227 }
12228
12229 async fn discover_history(
12230 &self,
12231 address: &SessionAddress,
12232 limit: u16,
12233 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12234 let query = HistoryQuery {
12235 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12236 limit,
12237 };
12238 query
12239 .validate()
12240 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12241 let snapshot = self
12242 .dispatch_history_bounded(
12243 address,
12244 ControlCommand::DiscoverHistory {
12245 instance_id: address.session.instance_id,
12246 query: query.clone(),
12247 },
12248 HistoryOperation::Discover { query },
12249 )
12250 .await?;
12251 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12252 return Err(failure(
12253 NodeFailureCode::BackendOperationFailed,
12254 "history discovery settled into an invalid state",
12255 ));
12256 }
12257 Ok(snapshot.history.candidates)
12258 }
12259
12260 async fn load_history(
12261 &self,
12262 address: &SessionAddress,
12263 candidate_id: String,
12264 ) -> Result<HistorySessionRecord, NodeFailure> {
12265 validate_candidate_id(&candidate_id)
12266 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12267 let snapshot = self
12268 .dispatch_history_bounded(
12269 address,
12270 ControlCommand::LoadHistory {
12271 instance_id: address.session.instance_id,
12272 candidate_id: candidate_id.clone(),
12273 },
12274 HistoryOperation::Load {
12275 candidate_id: candidate_id.clone(),
12276 },
12277 )
12278 .await?;
12279 if snapshot.history.pending.is_some()
12280 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12281 {
12282 return Err(failure(
12283 NodeFailureCode::BackendOperationFailed,
12284 "history load settled into an invalid state",
12285 ));
12286 }
12287 snapshot.history.loaded.ok_or_else(|| {
12288 failure(
12289 NodeFailureCode::BackendOperationFailed,
12290 "history load produced no bounded session",
12291 )
12292 })
12293 }
12294
12295 async fn export_context_pack_for_session_record(
12302 &self,
12303 record_id: &SessionRecordId,
12304 session: &SessionAddress,
12305 allow_clean_detachment: bool,
12306 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12307 let outcome = self
12313 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12314 .await;
12315 if let Err(failure) = &outcome {
12316 tracing::warn!(
12317 record = %record_id,
12318 code = ?failure.code,
12319 reason = %failure.message,
12320 "session-record context-pack export refused",
12321 );
12322 }
12323 outcome
12324 }
12325
12326 async fn export_context_pack_for_session_record_inner(
12327 &self,
12328 record_id: &SessionRecordId,
12329 session: &SessionAddress,
12330 allow_clean_detachment: bool,
12331 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12332 let step = |name: &'static str| move |failure: NodeFailure| {
12337 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12338 failure
12339 };
12340 let record = self.record(record_id).map_err(step("record-lookup"))?;
12341 let identity = match record.provider_session.clone() {
12357 Some(identity) => {
12358 if identity.key != ProviderSessionKey::SessionId {
12359 return Err(step("provider-identity-key")(failure(
12360 NodeFailureCode::BackendOperationFailed,
12361 "managed session provider identity cannot bind history exactly",
12362 )));
12363 }
12364 Some(identity)
12365 }
12366 None if record.state == ManagedSessionState::IdentityPending => {
12367 return Err(step("provider-session-identity")(failure(
12368 NodeFailureCode::BackendOperationFailed,
12369 "managed session has not yet observed its provider session identity",
12370 )));
12371 }
12372 None => None,
12373 };
12374 self.revalidate_session_record_context_export(
12375 &record,
12376 identity.as_ref(),
12377 session,
12378 allow_clean_detachment,
12379 ).map_err(step("revalidate-before-discovery"))?;
12380 let candidates = self
12381 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12382 .await
12383 .map_err(step("discover-history"))?;
12384 let candidate_id = select_history_candidate(
12389 &candidates,
12390 identity.as_ref().map(|identity| identity.id.as_str()),
12391 ).map_err(step("unique-candidate"))?;
12392 let loaded = self.load_history(session, candidate_id.clone()).await
12393 .map_err(step("load-history"))?;
12394 if let Some(identity) = identity.as_ref() {
12395 if loaded.session_id != identity.id {
12396 return Err(failure(
12397 NodeFailureCode::SessionRecordConflict,
12398 "loaded history identity does not match the managed session record",
12399 ));
12400 }
12401 }
12402 self.revalidate_session_record_context_export(
12403 &record,
12404 identity.as_ref(),
12405 session,
12406 allow_clean_detachment,
12407 )?;
12408 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12409 .map_err(step("revalidate-loaded-history"))?;
12410 let pack = self.materialize_context_pack(session).await
12411 .map_err(step("materialize-context-pack"))?;
12412 self.commit_context_pack_for_session_record(
12413 &record,
12414 identity.as_ref(),
12415 session,
12416 &candidate_id,
12417 &loaded,
12418 pack,
12419 allow_clean_detachment,
12420 )
12421 }
12422
12423 async fn export_context_pack(
12424 &self,
12425 address: &SessionAddress,
12426 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12427 let pack = self.materialize_context_pack(address).await?;
12428 self.commit_context_pack(pack)
12429 }
12430
12431 async fn materialize_context_pack(
12432 &self,
12433 address: &SessionAddress,
12434 ) -> Result<NodeContextPack, NodeFailure> {
12435 let snapshot = self.internal_session_snapshot(address)?;
12436 if !context_pack_source_status_is_usable(&snapshot.status) {
12437 return Err(failure(
12438 NodeFailureCode::InvalidRequest,
12439 "context export requires a running or successfully exited source session",
12440 ));
12441 }
12442 if snapshot.history.pending.is_some()
12443 || snapshot.history.loaded_candidate_id.is_none()
12444 {
12445 return Err(failure(
12446 NodeFailureCode::InvalidRequest,
12447 "context export requires settled loaded history",
12448 ));
12449 }
12450 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12451 failure(
12452 NodeFailureCode::InvalidRequest,
12453 "context export requires settled loaded history",
12454 )
12455 })?;
12456 let source_provider = snapshot.agent_id.clone();
12457 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12458 let repository = self
12459 .context_pack_repository(&address.workspace_id)
12460 .await?;
12461 let current = self.internal_session_snapshot(address)?;
12462 if !context_pack_source_status_is_usable(¤t.status)
12463 || current.agent_id != source_provider
12464 || current.history.pending.is_some()
12465 || current.history.loaded_candidate_id != loaded_candidate_id
12466 || current.history.loaded.as_ref() != Some(&loaded)
12467 {
12468 return Err(failure(
12469 NodeFailureCode::ContextPackMaterializationFailed,
12470 "context source changed during bounded repository capture",
12471 ));
12472 }
12473 NodeContextPack::export_with_repository(
12474 ContextPackLineageReceipt {
12475 source_node_id: self.node_id.clone(),
12476 source_session: address.clone(),
12477 source_provider,
12478 },
12479 &loaded,
12480 Some(repository),
12481 )
12482 .map_err(|_| {
12483 failure(
12484 NodeFailureCode::ContextPackMaterializationFailed,
12485 "bounded context pack export failed",
12486 )
12487 })
12488 }
12489
12490 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12496 let mut store = self
12497 .context_pack_store
12498 .lock()
12499 .unwrap_or_else(|poisoned| poisoned.into_inner());
12500 let Some(store) = store.as_mut() else {
12501 return Ok(());
12502 };
12503 store.commit(pack).map_err(|_| {
12504 failure(
12505 NodeFailureCode::ContextPackMaterializationFailed,
12506 "durable context pack commit failed",
12507 )
12508 })
12509 }
12510
12511 fn commit_context_pack(
12512 &self,
12513 pack: NodeContextPack,
12514 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12515 self.commit_context_pack_durably(&pack)?;
12516 self.context_catalog
12517 .write()
12518 .unwrap_or_else(|poisoned| poisoned.into_inner())
12519 .insert(pack)
12520 .map_err(|_| {
12521 failure(
12522 NodeFailureCode::ContextPackMaterializationFailed,
12523 "context pack catalog is full",
12524 )
12525 })
12526 }
12527
12528 fn commit_context_pack_for_session_record(
12529 &self,
12530 expected: &ManagedSessionRecord,
12531 identity: Option<&ProviderSessionIdentity>,
12532 session: &SessionAddress,
12533 candidate_id: &str,
12534 loaded: &HistorySessionRecord,
12535 pack: NodeContextPack,
12536 allow_clean_detachment: bool,
12537 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12538 let current_root = self.workspace_root(&expected.workspace_id)?;
12539 let _transaction = self
12540 .state_transaction
12541 .lock()
12542 .unwrap_or_else(|poisoned| poisoned.into_inner());
12543 let records = self
12544 .session_records
12545 .lock()
12546 .unwrap_or_else(|poisoned| poisoned.into_inner());
12547 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12548 failure(
12549 NodeFailureCode::SessionRecordConflict,
12550 "managed session record disappeared during context export",
12551 )
12552 })?;
12553 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12554 return Err(failure(
12555 NodeFailureCode::SessionRecordConflict,
12556 "managed session record changed during context export",
12557 ));
12558 }
12559 let bindings = self
12560 .session_bindings
12561 .lock()
12562 .unwrap_or_else(|poisoned| poisoned.into_inner());
12563 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12564 failure(
12565 NodeFailureCode::SessionRecordConflict,
12566 "managed session runtime binding disappeared during context export",
12567 )
12568 })?;
12569 let runtime = self
12570 .handle
12571 .snapshot()
12572 .sessions
12573 .iter()
12574 .find(|snapshot| {
12575 snapshot.instance_id == session.session.instance_id
12576 && snapshot.generation == session.session.generation
12577 })
12578 .cloned()
12579 .ok_or_else(|| {
12580 failure(
12581 NodeFailureCode::SessionRecordConflict,
12582 "managed session runtime disappeared during context export",
12583 )
12584 })?;
12585 let receipt = pack.receipt();
12586 if !session_record_context_export_binding_is_exact(
12587 expected,
12588 current,
12589 identity,
12590 session,
12591 binding,
12592 &runtime.agent_id,
12593 ¤t_root,
12594 allow_clean_detachment,
12595 ) || !session_record_context_export_source_is_usable(
12596 current,
12597 &runtime.status,
12598 allow_clean_detachment,
12599 ) || runtime.history.pending.is_some()
12600 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12601 || runtime.history.loaded.as_ref() != Some(loaded)
12602 || receipt.lineage.source_node_id != self.node_id
12603 || receipt.lineage.source_session != *session
12604 || receipt.lineage.source_provider != current.provider
12605 || receipt.source_message_count != loaded.message_count
12606 {
12607 return Err(failure(
12608 NodeFailureCode::SessionRecordConflict,
12609 "managed session source changed before context catalog commit",
12610 ));
12611 }
12612 self.commit_context_pack_durably(&pack)?;
12613 self.context_catalog
12614 .write()
12615 .unwrap_or_else(|poisoned| poisoned.into_inner())
12616 .insert(pack)
12617 .map_err(|_| {
12618 failure(
12619 NodeFailureCode::ContextPackMaterializationFailed,
12620 "context pack catalog is full",
12621 )
12622 })
12623 }
12624
12625 async fn context_pack_repository(
12626 &self,
12627 workspace_id: &WorkspaceId,
12628 ) -> Result<ContextPackRepository, NodeFailure> {
12629 let first = self
12630 .context_pack_repository_observation(workspace_id)
12631 .await?;
12632 let second = self
12633 .context_pack_repository_observation(workspace_id)
12634 .await?;
12635 stable_context_pack_repository(first, second)
12636 }
12637
12638 async fn context_pack_repository_observation(
12639 &self,
12640 workspace_id: &WorkspaceId,
12641 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12642 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12643 let head = if git.is_repository {
12644 let root = self.workspace_root(workspace_id)?;
12645 match resolve_base_commit_with_timeout(
12646 &root,
12647 "HEAD",
12648 GIT_COMMAND_TIMEOUT_MS,
12649 )
12650 .await
12651 {
12652 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12653 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12654 ContextPackRepositoryHead::Unborn
12655 }
12656 Err(_) => {
12657 return Err(failure(
12658 NodeFailureCode::ContextPackMaterializationFailed,
12659 "context pack HEAD observation failed",
12660 ));
12661 }
12662 }
12663 } else {
12664 ContextPackRepositoryHead::NotRepository
12665 };
12666 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12667 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12668 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12669 failure(
12670 NodeFailureCode::InvalidRepositoryPath,
12671 "context pack selected file path is invalid",
12672 )
12673 })?;
12674 match self.read_workspace_file(workspace_id.clone(), path).await {
12675 Ok(WorkspaceFileRead {
12676 content: WorkspaceFileContent::Utf8 { text, byte_len },
12677 ..
12678 }) => files.push(ContextPackRepositoryFileSource::utf8(
12679 selected_path,
12680 text,
12681 byte_len,
12682 )),
12683 Ok(WorkspaceFileRead {
12684 content: WorkspaceFileContent::NonUtf8 { byte_len },
12685 ..
12686 }) => files.push(ContextPackRepositoryFileSource::skipped(
12687 selected_path,
12688 ContextPackSelectedFileSkipReason::NonUtf8,
12689 Some(byte_len),
12690 )),
12691 Ok(WorkspaceFileRead {
12692 content: WorkspaceFileContent::TooLarge { .. },
12693 ..
12694 }) => files.push(ContextPackRepositoryFileSource::skipped(
12695 selected_path,
12696 ContextPackSelectedFileSkipReason::TooLarge,
12697 None,
12698 )),
12699 Err(error) => {
12700 let reason = if matches!(
12701 error.code,
12702 NodeFailureCode::InvalidRepositoryPath
12703 | NodeFailureCode::RepositoryFileNotRegular
12704 | NodeFailureCode::RepositoryPathUnsafe
12705 ) {
12706 ContextPackSelectedFileSkipReason::Unsafe
12707 } else {
12708 ContextPackSelectedFileSkipReason::Unavailable
12709 };
12710 files.push(ContextPackRepositoryFileSource::skipped(
12711 selected_path,
12712 reason,
12713 None,
12714 ));
12715 }
12716 }
12717 }
12718 Ok(ContextPackRepositoryObservation { head, git, files })
12719 }
12720
12721 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12722 let referenced_by_binding = self
12723 .session_bindings
12724 .lock()
12725 .unwrap_or_else(|poisoned| poisoned.into_inner())
12726 .values()
12727 .any(|binding| {
12728 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12729 });
12730 let referenced_by_record = self
12731 .session_records
12732 .lock()
12733 .unwrap_or_else(|poisoned| poisoned.into_inner())
12734 .records
12735 .values()
12736 .any(|record| record.context_id.as_ref() == Some(context_id));
12737 let referenced_by_materialization = self
12738 .materializations
12739 .lock()
12740 .unwrap_or_else(|poisoned| poisoned.into_inner())
12741 .values()
12742 .any(|ownership| {
12743 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12744 });
12745 if referenced_by_binding
12746 || referenced_by_record
12747 || referenced_by_materialization
12748 {
12749 return Err(failure(
12750 NodeFailureCode::ContextPackBusy,
12751 "context pack is still referenced",
12752 ));
12753 }
12754 let mut catalog = self.context_catalog
12755 .write()
12756 .unwrap_or_else(|poisoned| poisoned.into_inner());
12757 if catalog.get(context_id).is_none() {
12758 return Err(failure(
12759 NodeFailureCode::UnknownContextPack,
12760 "context pack is unavailable",
12761 ));
12762 }
12763 self.spawn_idempotency
12764 .lock()
12765 .unwrap_or_else(|poisoned| poisoned.into_inner())
12766 .entries
12767 .retain(|_, entry| !entry.value.references_context(context_id));
12768 let removed = catalog.remove(context_id);
12769 debug_assert!(removed.is_some(), "validated context pack remains present");
12770 Ok(())
12771 }
12772
12773 async fn dispatch_input_bounded(
12774 &self,
12775 address: &SessionAddress,
12776 action: InputAction,
12777 ) -> Result<(), NodeFailure> {
12778 let started_after = self.current_sequence();
12779 self.pty_input_marks
12780 .lock()
12781 .unwrap_or_else(|poisoned| poisoned.into_inner())
12782 .insert(address.session.instance_id, (Instant::now(), false));
12783 self.pty_input_wake.notify_one();
12784 let command_id = self
12785 .dispatch_bounded(
12786 ControlCommand::SendInput {
12787 instance_id: address.session.instance_id,
12788 action,
12789 },
12790 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12791 )
12792 .await?;
12793 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12797 let mut scan_after = started_after;
12798 let mut accepted_after = None;
12799 loop {
12800 let events = self
12801 .history
12802 .lock()
12803 .unwrap_or_else(|poisoned| poisoned.into_inner())
12804 .events
12805 .iter()
12806 .filter(|envelope| envelope.sequence > scan_after)
12807 .cloned()
12808 .collect::<Vec<_>>();
12809 for envelope in events {
12810 scan_after = scan_after.max(envelope.sequence);
12811 let NodeEvent::Control {
12812 address: event_address,
12813 event,
12814 } = envelope.event
12815 else {
12816 continue;
12817 };
12818 if event_address != *address {
12819 continue;
12820 }
12821 if event.command_id == Some(command_id) {
12822 match &event.event {
12823 ControlEventKind::CommandRejected { message } => {
12824 return Err(failure(NodeFailureCode::InvalidRequest, message));
12825 }
12826 ControlEventKind::InputRequested { .. } => {
12827 accepted_after = Some(envelope.sequence);
12828 continue;
12829 }
12830 _ => {}
12831 }
12832 }
12833 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12834 match &event.event {
12835 ControlEventKind::InputCompleted { .. } => return Ok(()),
12836 ControlEventKind::InputFailed { message, .. } => {
12837 return Err(failure(
12838 NodeFailureCode::BackendOperationFailed,
12839 message,
12840 ));
12841 }
12842 _ => {}
12843 }
12844 }
12845 }
12846 if Instant::now() >= deadline {
12847 return Err(failure(
12848 NodeFailureCode::BackendBusy,
12849 if accepted_after.is_some() {
12850 "input was accepted but did not settle before the bounded deadline"
12851 } else {
12852 "input was not accepted before the bounded deadline"
12853 },
12854 ));
12855 }
12856 sleep(Duration::from_millis(2)).await;
12857 }
12858 }
12859
12860 async fn dispatch_resize_bounded(
12861 &self,
12862 address: &SessionAddress,
12863 size: gate4agent_types::TerminalSize,
12864 ) -> Result<(), NodeFailure> {
12865 let started_after = self.current_sequence();
12866 let command_id = self
12867 .dispatch_bounded(
12868 ControlCommand::Resize {
12869 instance_id: address.session.instance_id,
12870 size,
12871 },
12872 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12873 )
12874 .await?;
12875 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12876 let mut scan_after = started_after;
12877 let mut accepted_after = None;
12878 loop {
12879 let events = self
12880 .history
12881 .lock()
12882 .unwrap_or_else(|poisoned| poisoned.into_inner())
12883 .events
12884 .iter()
12885 .filter(|envelope| envelope.sequence > scan_after)
12886 .cloned()
12887 .collect::<Vec<_>>();
12888 for envelope in events {
12889 scan_after = scan_after.max(envelope.sequence);
12890 let NodeEvent::Control {
12891 address: event_address,
12892 event,
12893 } = envelope.event
12894 else {
12895 continue;
12896 };
12897 if event_address != *address {
12898 continue;
12899 }
12900 if event.command_id == Some(command_id) {
12901 match &event.event {
12902 ControlEventKind::CommandRejected { message } => {
12903 return Err(failure(NodeFailureCode::InvalidRequest, message));
12904 }
12905 ControlEventKind::ResizeRequested {
12906 size: requested_size,
12907 ..
12908 } if *requested_size == size => {
12909 accepted_after = Some(envelope.sequence);
12910 continue;
12911 }
12912 _ => {}
12913 }
12914 }
12915 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12916 match &event.event {
12917 ControlEventKind::Resized { size: settled_size }
12918 if *settled_size == size =>
12919 {
12920 return Ok(());
12921 }
12922 ControlEventKind::ResizeFailed { message } => {
12923 return Err(failure(
12924 NodeFailureCode::BackendOperationFailed,
12925 message,
12926 ));
12927 }
12928 _ => {}
12929 }
12930 }
12931 }
12932 if Instant::now() >= deadline {
12933 return Err(failure(
12934 NodeFailureCode::BackendBusy,
12935 if accepted_after.is_some() {
12936 "resize was accepted but did not settle before the bounded deadline"
12937 } else {
12938 "resize was not accepted before the bounded deadline"
12939 },
12940 ));
12941 }
12942 sleep(Duration::from_millis(2)).await;
12943 }
12944 }
12945
12946 async fn spawn_session(
12947 &self,
12948 workspace_id: WorkspaceId,
12949 provider: AgentId,
12950 mode: SessionMode,
12951 terminal_size: gate4agent_types::TerminalSize,
12952 initial_prompt: Option<String>,
12953 ) -> Result<SessionAddress, NodeFailure> {
12954 if self.managed_worktrees.lock()
12955 .unwrap_or_else(|poisoned| poisoned.into_inner())
12956 .lease_for_workspace(&workspace_id)
12957 .is_some()
12958 {
12959 return Err(failure(
12960 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12961 "managed worktree targets may only be spawned through their lease request",
12962 ));
12963 }
12964 self
12965 .spawn_session_with_deadline(
12966 workspace_id,
12967 provider.clone(),
12968 mode,
12969 terminal_size,
12970 initial_prompt,
12971 None,
12972 None,
12973 None,
12974 None,
12975 None,
12976 SpawnRecordPolicy::ProviderIdentityOnly,
12977 None,
12978 &[],
12979 None,
12980 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
12987 None,
12988 None,
12989 )
12990 .await
12991 .map(|(session, _runtime_policy)| session)
12992 }
12993
12994 async fn spawn_session_with_deadline(
12995 &self,
12996 workspace_id: WorkspaceId,
12997 provider: AgentId,
12998 mode: SessionMode,
12999 terminal_size: gate4agent_types::TerminalSize,
13000 initial_prompt: Option<String>,
13001 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13002 bundle: Option<ResolvedBundleReceipt>,
13003 context: Option<ResolvedContextPackReceipt>,
13004 managed_authority: Option<ManagedWorktreeLeaseId>,
13005 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13006 record_policy: SpawnRecordPolicy,
13007 deadline: Option<Instant>,
13008 required_capabilities: &[ProviderRuntimeCapability],
13009 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13010 approval_level: ApprovalLevel,
13011 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13012 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13018 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13019 let reserved_lease = self.managed_worktrees.lock()
13020 .unwrap_or_else(|poisoned| poisoned.into_inner())
13021 .lease_for_workspace(&workspace_id);
13022 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13023 (Some(expected), Some(provided)) if expected == provided => {}
13024 (None, None) => {}
13025 _ => {
13026 return Err(failure(
13027 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13028 "spawn lacks exact managed worktree lease authority",
13029 ));
13030 }
13031 }
13032 self.ensure_workspace_environment_profile_compatible(
13036 &workspace_id,
13037 environment_profile.as_ref(),
13038 )?;
13039 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13040 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13041 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13042 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13043 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13044 };
13045 let transport = match mode {
13051 SessionMode::Pty => TransportKind::Pty,
13052 SessionMode::Inline => TransportKind::Pipe,
13053 SessionMode::Acp => TransportKind::Acp,
13054 };
13055 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13056 let acp_transport = self.provider_supports_acp_transport(&provider);
13057 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13058 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13059 NodeFailureCode::UnsupportedSpawnCapability,
13060 "pre-admitted provider runtime no longer satisfies spawn requirements",
13061 ))?;
13062 runtime_policy
13063 } else if let Some(deadline) = deadline {
13064 let remaining = spawn_deadline_remaining(deadline)?;
13065 timeout(
13066 remaining,
13067 self.admit_provider_runtime(&provider, runtime_requirement),
13068 )
13069 .await
13070 .map_err(|_| {
13071 failure(
13072 NodeFailureCode::SpawnDeadlineExceeded,
13073 "provider admission exceeded the spawn deadline",
13074 )
13075 })??
13076 } else {
13077 self.admit_provider_runtime(&provider, runtime_requirement).await?
13078 };
13079 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13080 let runtime_policy =
13092 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13093 if required_capabilities
13094 .iter()
13095 .any(|capability| !runtime_policy.admits(*capability))
13096 {
13097 return Err(failure(
13098 NodeFailureCode::UnsupportedSpawnCapability,
13099 "provider runtime does not admit a required spawn capability",
13100 ));
13101 }
13102 if let Some(deadline) = deadline {
13103 spawn_deadline_remaining(deadline)?;
13104 }
13105 self.ensure_binding_capacity()?;
13106 let mut working_directory = self.workspace_root(&workspace_id)?;
13107 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13108 let session = SessionKey {
13109 instance_id,
13110 generation: SessionGeneration(1),
13111 };
13112 let address = SessionAddress {
13113 workspace_id: workspace_id.clone(),
13114 session,
13115 };
13116 tracing::info!(
13124 node_id = %self.node_id,
13125 workspace_id = %workspace_id,
13126 provider = %provider,
13127 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13128 session = ?address.session,
13129 "session spawn request accepted",
13130 );
13131 let mut prepared_spawn = SpawnPreparer::prepare(
13132 self,
13133 SpawnPrepareRequest {
13134 instance_id,
13135 address: &address,
13136 provider: &provider,
13137 mode,
13138 environment_profile: environment_profile.as_ref(),
13139 bundle: bundle.as_ref(),
13140 context: context.as_ref(),
13141 managed_authority: managed_authority.as_ref(),
13142 harness_mcp,
13143 approval_level,
13144 network_allowlist: network_allowlist.as_ref(),
13145 browser_profile_id,
13146 },
13147 )?;
13148 let materialization_id = prepared_spawn.materialization_id.take();
13149 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13150 let record_id = self.bind_spawn_session_with_materialization(
13151 &address,
13152 provider.clone(),
13153 mode,
13154 runtime_policy,
13155 record_policy,
13156 environment_profile,
13157 bundle,
13158 context,
13159 materialization_id,
13160 )?;
13161 prepared_spawn.retain_session_guards();
13162 let session_provider = provider.clone();
13166 let agent_id = provider;
13167 let register_command_id = match self.dispatch_bounded(
13168 ControlCommand::Register {
13169 instance_id,
13170 agent_id,
13171 transport,
13172 },
13173 dispatch_timeout,
13174 )
13175 .await {
13176 Ok(command_id) => command_id,
13177 Err(error) => {
13178 if let Some(binding) = self.remove_binding(&address) {
13179 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13180 }
13181 if let Some(record_id) = record_id.as_ref() {
13182 self.discard_record(record_id)?;
13183 }
13184 return Err(spawn_dispatch_error(error, deadline));
13185 }
13186 };
13187 let start_timeout = match spawn_dispatch_timeout(deadline) {
13188 Ok(timeout) => timeout,
13189 Err(error) => {
13190 let recovery = self
13191 .rollback_spawn(
13192 &address,
13193 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13194 )
13195 .await;
13196 return match recovery {
13197 Ok(()) => {
13198 if let Some(record_id) = record_id.as_ref() {
13199 self.discard_record(record_id)?;
13200 }
13201 Err(error)
13202 }
13203 Err(recovery_error) => {
13204 if let Some(record_id) = record_id.as_ref() {
13205 self.mark_record_error(
13206 record_id,
13207 "spawn deadline elapsed and registration recovery failed",
13208 )?;
13209 }
13210 Err(recovery_error)
13211 }
13212 };
13213 }
13214 };
13215 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13216 if let Err(recovery_error) = self
13217 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13218 .await
13219 {
13220 return Err(recovery_error);
13221 }
13222 if let Some(record_id) = record_id.as_ref() {
13223 self.discard_record(record_id)?;
13224 }
13225 return Err(start_error);
13226 }
13227 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13229 working_directory = dir.to_string_lossy().into_owned();
13230 }
13231 let start_result = self
13232 .dispatch_bounded(
13233 ControlCommand::Start {
13234 instance_id,
13235 runtime_policy,
13236 request: StartRequest {
13237 working_directory,
13238 terminal_size,
13239 initial_prompt,
13240 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13241 approval_level,
13242 },
13243 },
13244 start_timeout,
13245 )
13246 .await;
13247 let start_command_id = match start_result {
13248 Ok(command_id) => command_id,
13249 Err(start_error) => {
13250 let start_error = spawn_dispatch_error(start_error, deadline);
13251 let recovery = self
13252 .rollback_spawn(
13253 &address,
13254 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13255 )
13256 .await;
13257 return match recovery {
13258 Ok(()) => {
13259 if let Some(record_id) = record_id.as_ref() {
13260 self.discard_record(record_id)?;
13261 }
13262 Err(start_error)
13263 }
13264 Err(recovery_error) => {
13265 let diagnostic = format!(
13266 "start failed and registration recovery failed: {}",
13267 recovery_error.message,
13268 );
13269 if let Some(record_id) = record_id.as_ref() {
13270 self.mark_record_error(record_id, &diagnostic)?;
13271 }
13272 Err(failure(recovery_error.code, &diagnostic))
13273 }
13274 };
13275 }
13276 };
13277
13278 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13279 loop {
13280 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13289 .or_else(|| self.take_rejected_command_reason(start_command_id))
13290 {
13291 self.rollback_spawn(
13292 &address,
13293 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13294 ).await.map_err(|error| {
13295 failure(
13296 error.code,
13297 &format!(
13298 "spawn was rejected ({}) and registration recovery failed: {}",
13299 reason.message, error.message
13300 ),
13301 )
13302 })?;
13303 if let Some(record_id) = record_id.as_ref() {
13304 self.discard_record(record_id)?;
13305 }
13306 let code = if reason.unsupported_transport {
13307 NodeFailureCode::UnsupportedTransport
13308 } else {
13309 NodeFailureCode::BackendOperationFailed
13310 };
13311 return Err(failure(code, &reason.message));
13312 }
13313 if let Some(current) = self
13314 .handle
13315 .snapshot()
13316 .sessions
13317 .iter()
13318 .find(|current| current.instance_id == instance_id)
13319 {
13320 if current.generation != session.generation {
13321 let divergence = failure(
13322 NodeFailureCode::StaleGeneration,
13323 "spawn generation diverged",
13324 );
13325 return match self
13326 .rollback_spawn(
13327 &address,
13328 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13329 )
13330 .await
13331 {
13332 Ok(()) => {
13333 if let Some(record_id) = record_id.as_ref() {
13334 self.discard_record(record_id)?;
13335 }
13336 Err(divergence)
13337 }
13338 Err(recovery_error) => {
13339 if let Some(record_id) = record_id.as_ref() {
13340 self.mark_record_error(
13341 record_id,
13342 "spawn generation diverged and recovery failed",
13343 )?;
13344 }
13345 Err(recovery_error)
13346 }
13347 };
13348 }
13349 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13350 prepared_spawn.retain_launch_guards();
13351 return Ok((address, runtime_policy));
13352 }
13353 }
13354 if Instant::now() >= commit_deadline {
13355 self.rollback_spawn(
13356 &address,
13357 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13358 ).await.map_err(|error| {
13359 failure(
13360 error.code,
13361 &format!(
13362 "start did not commit and registration recovery failed: {}",
13363 error.message
13364 ),
13365 )
13366 })?;
13367 if let Some(record_id) = record_id.as_ref() {
13368 self.discard_record(record_id)?;
13369 }
13370 return Err(failure(
13371 if deadline.is_some() {
13372 NodeFailureCode::SpawnDeadlineExceeded
13373 } else {
13374 NodeFailureCode::BackendBusy
13375 },
13376 "start did not commit before the bounded deadline; registration was removed",
13377 ));
13378 }
13379 sleep(Duration::from_millis(2)).await;
13380 }
13381 }
13382
13383 fn effective_spawn_runtime_policy(
13384 &self,
13385 runtime_policy: ProviderRuntimePolicy,
13386 ) -> ProviderRuntimePolicy {
13387 #[cfg(feature = "fixture")]
13388 let runtime_policy = if self.fixture_semantic_hook_policy {
13389 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13390 .expect("monitoring Hook fixture policy is internally valid")
13391 } else {
13392 runtime_policy
13393 };
13394 runtime_policy
13395 }
13396
13397 fn verify_harness_mcp_before_start(
13398 &self,
13399 prepared: Option<&PreparedHarnessMcpSpawn>,
13400 ) -> Result<(), NodeFailure> {
13401 prepared
13402 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13403 .transpose()
13404 .map(|_| ())
13405 .map_err(harness_mcp_failure)
13406 }
13407
13408 async fn rollback_spawn(
13409 &self,
13410 address: &SessionAddress,
13411 commit_timeout: Duration,
13412 ) -> Result<(), NodeFailure> {
13413 self.dispatch_bounded(
13414 ControlCommand::Remove {
13415 instance_id: address.session.instance_id,
13416 },
13417 commit_timeout,
13418 )
13419 .await?;
13420 self.wait_until_removed(address, commit_timeout).await?;
13421 if let Some(binding) = self.remove_binding(address) {
13422 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13423 }
13424 Ok(())
13425 }
13426
13427 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13428 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13429 self.dispatch_bounded(
13430 ControlCommand::Remove {
13431 instance_id: address.session.instance_id,
13432 },
13433 commit_timeout,
13434 )
13435 .await?;
13436 self.wait_until_removed(address, commit_timeout).await?;
13437 let removed = self.remove_binding(address);
13438 if let Some(binding) = removed.as_ref() {
13439 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13440 }
13441 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13442 let snapshot = self.managed_worktrees.lock()
13443 .unwrap_or_else(|poisoned| poisoned.into_inner())
13444 .release_session(
13445 &lease_id,
13446 self.incarnation_id,
13447 address.session.instance_id,
13448 address.session.generation,
13449 unix_time_ms(),
13450 );
13451 if self.persist_state().is_err() {
13452 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13453 }
13454 if let Some(snapshot) = snapshot {
13455 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13456 }
13457 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13458 }
13459 Ok(())
13460 }
13461
13462 async fn wait_until_removed(
13508 &self,
13509 address: &SessionAddress,
13510 commit_timeout: Duration,
13511 ) -> Result<(), NodeFailure> {
13512 let deadline = Instant::now() + commit_timeout;
13513 let mut next_dispatch = Instant::now();
13514 loop {
13515 let snapshot = self.handle.snapshot();
13516 let status = snapshot
13517 .sessions
13518 .iter()
13519 .find(|session| session.instance_id == address.session.instance_id)
13520 .map(|session| session.status.clone());
13521 let Some(status) = status else {
13522 return Ok(());
13523 };
13524 let now = Instant::now();
13525 if now >= deadline {
13526 return Err(failure(
13527 NodeFailureCode::BackendBusy,
13528 "remove did not commit before the bounded deadline; session binding was retained",
13529 ));
13530 }
13531 if status.allows_remove() && now >= next_dispatch {
13532 let _ = self.dispatch(ControlCommand::Remove {
13533 instance_id: address.session.instance_id,
13534 });
13535 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13536 }
13537 sleep(Duration::from_millis(2)).await;
13538 }
13539 }
13540
13541 fn resync(&self, after_sequence: u64) -> NodeResponse {
13542 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13543 let event_sequence = history.last_sequence;
13544 let oldest_available_sequence = history.replay_floor_sequence;
13545 let events = history
13546 .events
13547 .iter()
13548 .filter(|event| event.sequence > after_sequence)
13549 .cloned()
13550 .collect();
13551 drop(history);
13552 NodeResponse::Resync {
13553 event_sequence,
13554 oldest_available_sequence,
13555 snapshot: self.snapshot(),
13556 events,
13557 }
13558 }
13559}
13560
13561fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13566 match kind {
13567 AgentStreamChunkKindV1::Text { .. } => "text",
13568 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13569 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13570 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13571 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13572 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13573 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13574 }
13575}
13576
13577async fn accept_connections(
13591 endpoint: &str,
13592 call_home: Option<std::net::SocketAddr>,
13593 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13594 shared: Arc<NodeShared>,
13595) -> Result<(), NodeServerError> {
13596 let mut connections = JoinSet::new();
13597 let result = match call_home {
13598 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13599 Some(relay) => {
13600 let listener =
13601 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13602 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13603 tokio::pin!(listener);
13604 tokio::pin!(dialler);
13605 tokio::select! {
13606 result = &mut listener => result,
13607 result = &mut dialler => result,
13608 }
13609 }
13610 };
13611 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13612 while !connections.is_empty() {
13613 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13614 if remaining.is_zero()
13615 || timeout(remaining, connections.join_next()).await.is_err()
13616 {
13617 break;
13618 }
13619 }
13620 connections.abort_all();
13621 while connections.join_next().await.is_some() {}
13622 result
13623}
13624
13625const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13629
13630async fn call_home_loop(
13644 relay: std::net::SocketAddr,
13645 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13646 shared: Arc<NodeShared>,
13647) -> Result<(), NodeServerError> {
13648 let mut failures = 0_usize;
13649 loop {
13650 if shared.shutdown.load(Ordering::Acquire) {
13651 return Ok(());
13652 }
13653 let permit = tokio::select! {
13654 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13655 permit.map_err(|_| NodeServerError::PreauthClosed)?
13656 }
13657 _ = shared.shutdown_notify.notified() => return Ok(()),
13658 };
13659 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13660 Ok(()) => {
13661 failures = 0;
13665 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13666 }
13667 Err(error) => {
13668 failures = failures.saturating_add(1);
13669 tracing::warn!(
13670 node_id = %shared.node_id,
13671 %relay,
13672 cause = %error,
13673 attempt = failures,
13674 "call-home connection failed",
13675 );
13676 let backoff = call_home_backoff(failures);
13677 tokio::select! {
13678 _ = sleep(backoff) => {}
13679 _ = shared.shutdown_notify.notified() => return Ok(()),
13680 }
13681 }
13682 }
13683 }
13684}
13685
13686const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13688const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13690
13691fn call_home_keepalive() -> socket2::TcpKeepalive {
13692 let keepalive = socket2::TcpKeepalive::new()
13693 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13694 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13695 #[cfg(not(windows))]
13696 let keepalive = keepalive.with_retries(3);
13697 keepalive
13698}
13699
13700fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13701 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13702}
13703
13704pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13706 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13707}
13708
13709fn call_home_backoff(attempt: usize) -> Duration {
13711 Duration::from_millis(
13712 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13713 )
13714}
13715
13716
13717async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13720 #[cfg(feature = "wireguard")]
13721 {
13722 let std_stream = tokio::task::spawn_blocking(move || {
13723 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13724 })
13725 .await
13726 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13727 .map_err(NodeServerError::CallHomeTransport)?;
13728 std_stream
13729 .set_nonblocking(true)
13730 .map_err(NodeServerError::CallHomeTransport)?;
13731 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13732 }
13733 #[cfg(not(feature = "wireguard"))]
13734 {
13735 tokio::net::TcpStream::connect(relay)
13736 .await
13737 .map_err(NodeServerError::CallHomeTransport)
13738 }
13739}
13740
13741async fn dial_relay_and_serve(
13743 relay: std::net::SocketAddr,
13744 identity: Option<&hatchery_enroll::NodeIdentity>,
13745 shared: Arc<NodeShared>,
13746 preauth_permit: OwnedSemaphorePermit,
13747) -> Result<(), NodeServerError> {
13748 let mut stream = dial_call_home(relay).await?;
13749 let _ = stream.set_nodelay(true);
13753 if let Err(error) = enable_call_home_keepalive(&stream) {
13759 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13760 }
13761 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13762 .await
13763 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13764 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13765 let mut session_token = None;
13766 if let Some(identity) = identity {
13767 let mut magic = [0_u8; 4];
13771 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13772 .await
13773 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13774 .map_err(NodeServerError::CallHomeTransport)?;
13775 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13776 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13777 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13778 .await
13779 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13780 .map_err(NodeServerError::CallHomeTransport)?;
13781 let (answer, token) = identity
13782 .answer_challenge(shared.node_id.as_str(), &frame)
13783 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13784 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13785 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13786 session_token = Some(token);
13787 }
13788 }
13789 serve_connection(stream, shared, session_token, preauth_permit).await
13790}
13791
13792async fn accept_connections_inner(
13793 endpoint: &str,
13794 shared: Arc<NodeShared>,
13795 connections: &mut JoinSet<Result<(), NodeServerError>>,
13796) -> Result<(), NodeServerError> {
13797 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13798 loop {
13799 while connections.try_join_next().is_some() {}
13800 if shared.shutdown.load(Ordering::Acquire) {
13801 return Ok(());
13802 }
13803 let preauth_permit = tokio::select! {
13804 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13805 permit.map_err(|_| NodeServerError::PreauthClosed)?
13806 }
13807 _ = shared.shutdown_notify.notified() => return Ok(()),
13808 };
13809 let server = tokio::select! {
13810 result = listener.accept() => result?,
13811 _ = shared.shutdown_notify.notified() => return Ok(()),
13812 };
13813 let connection_shared = Arc::clone(&shared);
13814 connections.spawn(async move {
13815 serve_connection(server, connection_shared, None, preauth_permit).await
13816 });
13817 if shared.shutdown.load(Ordering::Acquire) {
13818 return Ok(());
13819 }
13820 }
13821}
13822
13823async fn serve_connection<S>(
13831 mut pipe: S,
13832 shared: Arc<NodeShared>,
13833 session_token: Option<String>,
13834 preauth_permit: OwnedSemaphorePermit,
13835) -> Result<(), NodeServerError>
13836where
13837 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13838{
13839 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13841 let hello_frame = timeout(
13842 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13843 read_json_frame_limited_body_timeout(
13844 &mut pipe,
13845 MAX_NODE_HELLO_FRAME_BYTES,
13846 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13847 ),
13848 )
13849 .await
13850 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13851 let ClientFrame::Hello(hello) = hello_frame else {
13852 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13853 };
13854 if hello.build_stamp != BUILD_STAMP {
13855 return Err(NodeServerError::Handshake(format!(
13856 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13857 hello.build_stamp,
13858 )));
13859 }
13860 let compatibility = match hello.compatibility.as_ref() {
13861 Some(offer) => {
13862 let mut selected = node_compatibility_support(&shared)?
13863 .negotiate(offer)
13864 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13865 project_negotiated_provider_ids(&mut selected);
13866 Some(selected)
13867 }
13868 None => None,
13869 };
13870 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13871 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13872 (Some(offer), Some(selected)) => negotiated_auth_proof(
13873 auth_token,
13874 AuthDirection::Server,
13875 hello.role,
13876 &hello.client_nonce,
13877 &server_nonce,
13878 offer,
13879 selected,
13880 ),
13881 (None, None) => auth_proof(
13882 auth_token,
13883 AuthDirection::Server,
13884 hello.role,
13885 &hello.client_nonce,
13886 &server_nonce,
13887 ),
13888 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13889 }
13890 .map_err(NodeServerError::Authentication)?;
13891 write_json_frame_limited(
13892 &mut pipe,
13893 &ServerFrame::Challenge(ServerChallenge {
13894 build_stamp: BUILD_STAMP.to_owned(),
13895 server_nonce,
13896 server_proof,
13897 compatibility: compatibility.clone(),
13898 }),
13899 MAX_NODE_HELLO_FRAME_BYTES,
13900 )
13901 .await?;
13902 let authentication = timeout(
13903 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13904 read_json_frame_limited_body_timeout(
13905 &mut pipe,
13906 MAX_NODE_HELLO_FRAME_BYTES,
13907 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13908 ),
13909 )
13910 .await
13911 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13912 let ClientFrame::Authenticate(authentication) = authentication else {
13913 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13914 };
13915 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13916 (Some(offer), Some(selected)) => negotiated_auth_proof(
13917 auth_token,
13918 AuthDirection::Client,
13919 hello.role,
13920 &hello.client_nonce,
13921 &server_nonce,
13922 offer,
13923 selected,
13924 ),
13925 (None, None) => auth_proof(
13926 auth_token,
13927 AuthDirection::Client,
13928 hello.role,
13929 &hello.client_nonce,
13930 &server_nonce,
13931 ),
13932 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13933 }
13934 .map_err(NodeServerError::Authentication)?;
13935 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13936 return Err(NodeServerError::Handshake("access denied".to_owned()));
13937 }
13938 let selected_capabilities = compatibility
13939 .as_ref()
13940 .map(|selected| selected.capabilities.clone())
13941 .unwrap_or_default();
13942 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13943 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13944 });
13945 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13946 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13947 });
13948 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13949 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13950 });
13951 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13952 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13953 });
13954 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13955 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13956 });
13957 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13958 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13959 }) && selected_capabilities.iter().any(|capability| {
13960 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13961 });
13962 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13963 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13964 });
13965 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13966 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13967 });
13968 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13969 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13970 });
13971 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13972 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13973 });
13974 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13975 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13976 });
13977 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13978 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13979 });
13980 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13981 .try_acquire_owned()
13982 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13983 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13984 let _release_guard = ControllerReleaseGuard {
13985 shared: Arc::clone(&shared),
13986 connection_id,
13987 };
13988 drop(preauth_permit);
13989 let _authenticated_permit = authenticated_permit;
13990 let mut event_rx = shared.event_tx.subscribe();
13991 let mut terminal_event_rx = terminal_event_subscription(
13992 &shared,
13993 include_terminal_frame_events,
13994 );
13995 let mut agent_stream_event_rx = agent_stream_event_subscription(
13996 &shared,
13997 include_agent_stream_events,
13998 );
13999 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14000 &shared,
14001 include_harness_mcp_proxy,
14002 );
14003 write_json_frame(
14004 &mut pipe,
14005 &ServerFrame::Hello(NodeHello {
14006 build_stamp: BUILD_STAMP.to_owned(),
14007 incarnation_id: shared.incarnation_id,
14008 connection_id,
14009 role: hello.role,
14010 event_sequence: shared.current_sequence(),
14011 controller: shared.controller_state(),
14012 snapshot: snapshot_for_wire(
14013 &shared,
14014 include_provider_runtime_status,
14015 include_open_provider_ids,
14016 include_spawn_profiles,
14017 include_managed_worktrees,
14018 include_child_environment_profiles,
14019 include_session_bundles,
14020 include_history_context_packs,
14021 include_agent_progress,
14022 include_session_task_correlation,
14023 ),
14024 compatibility,
14025 }),
14026 )
14027 .await?;
14028 let (mut reader, mut writer) = tokio::io::split(pipe);
14029 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14030 let reader_task = tokio::spawn(async move {
14031 loop {
14032 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14033 &mut reader,
14034 MAX_NODE_CLIENT_FRAME_BYTES,
14035 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14036 )
14037 .await;
14038 let terminal = frame.is_err();
14039 if frame_tx.send(frame).await.is_err() || terminal {
14040 break;
14041 }
14042 }
14043 });
14044 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14045 let mut pending_events = Vec::new();
14046 let mut resync_required = false;
14047 let mut discard_events_through = 0_u64;
14048 loop {
14049 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14054 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14055 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14056 for _ in 0..durable_drain_budget {
14057 match event_rx.try_recv() {
14058 Ok(event) => queue_connection_event(
14059 &mut pending_events,
14060 event,
14061 discard_events_through,
14062 ),
14063 Err(broadcast::error::TryRecvError::Empty) => break,
14064 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14065 resync_required = true;
14066 break;
14067 }
14068 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14069 }
14070 }
14071 if let Some(receiver) = terminal_event_rx.as_mut() {
14072 match receiver.try_recv() {
14073 Ok(events) => queue_connection_event_batch(
14074 &mut pending_events,
14075 &events,
14076 discard_events_through,
14077 ),
14078 Err(broadcast::error::TryRecvError::Empty) => {}
14079 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14080 resync_required = true;
14081 }
14082 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14083 }
14084 }
14085 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14086 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14096 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14097 for _ in 0..agent_stream_drain_budget {
14098 match receiver.try_recv() {
14099 Ok(events) => queue_connection_event_batch(
14100 &mut pending_events,
14101 &events,
14102 discard_events_through,
14103 ),
14104 Err(broadcast::error::TryRecvError::Empty) => break,
14105 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14106 tracing::warn!(
14107 connection_id,
14108 dropped,
14109 "agent stream event subscription lagged; forcing a resync",
14110 );
14111 resync_required = true;
14112 break;
14113 }
14114 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14115 }
14116 }
14117 }
14118 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14119 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14125 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14126 for _ in 0..harness_mcp_proxy_drain_budget {
14127 match receiver.try_recv() {
14128 Ok(events) => queue_connection_event_batch(
14129 &mut pending_events,
14130 &events,
14131 discard_events_through,
14132 ),
14133 Err(broadcast::error::TryRecvError::Empty) => break,
14134 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14135 tracing::warn!(
14136 connection_id,
14137 dropped,
14138 "harness MCP proxy event subscription lagged; forcing a resync",
14139 );
14140 resync_required = true;
14141 break;
14142 }
14143 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14144 }
14145 }
14146 }
14147 if resync_required {
14148 let event = resync_required_event(&shared);
14149 apply_connection_resync_watermark(
14150 &mut pending_events,
14151 &mut discard_events_through,
14152 event.sequence,
14153 );
14154 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14155 resync_required = false;
14156 continue;
14157 }
14158 if !pending_events.is_empty() {
14159 pending_events.sort_unstable_by_key(|event| event.sequence);
14160 let burst_len = connection_event_burst_len(pending_events.len());
14161 shared
14162 .connection_events_sent
14163 .fetch_add(burst_len as u64, Ordering::Relaxed);
14164 for event in pending_events.drain(..burst_len) {
14165 let event = if include_managed_worktrees {
14166 Some(event)
14167 } else {
14168 project_event_without_managed_worktrees(event)
14169 };
14170 let event = event.and_then(|event| if include_open_provider_ids {
14171 Some(event)
14172 } else {
14173 project_event_legacy_provider_ids(&shared, event)
14174 });
14175 let event = event.map(|event| if include_child_environment_profiles {
14176 event
14177 } else {
14178 project_event_without_child_environment_profile(event)
14179 });
14180 let event = event.map(|event| if include_session_bundles {
14181 event
14182 } else {
14183 project_event_without_session_bundle(event)
14184 });
14185 let event = event.map(|event| {
14186 project_event_history_for_wire(event, include_history_context_packs)
14187 });
14188 let event = event.map(|event| if include_history_context_packs {
14189 event
14190 } else {
14191 project_event_without_context_pack(event)
14192 });
14193 let event = event.map(|event| if include_session_task_correlation {
14194 event
14195 } else {
14196 project_event_without_session_task_binding(event)
14197 });
14198 let exact_harness_controller = hello.role == ClientRole::Operator
14199 && shared.controller_state().is_some_and(|controller| {
14200 controller.connection_id == connection_id
14201 });
14202 let event = event.and_then(|event| {
14203 if include_harness_mcp_proxy && exact_harness_controller {
14204 Some(event)
14205 } else {
14206 project_event_without_harness_mcp_proxy(event)
14207 }
14208 });
14209 if let Some(event) = event {
14210 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14211 }
14212 }
14213 }
14214 tokio::select! {
14215 biased;
14216 frame = frame_rx.recv() => {
14217 let frame = match frame {
14218 Some(frame) => frame,
14219 None => break,
14220 };
14221 let frame = match frame {
14222 Ok(frame) => frame,
14223 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14224 Err(error) => return Err(error.into()),
14225 };
14226 let ClientFrame::Request(request) = frame else {
14227 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14228 };
14229 #[cfg(feature = "fixture")]
14230 let probe_spawn_managed_worktree_v2 = matches!(
14231 &request.request,
14232 NodeRequest::SpawnManagedWorktreeV2 { .. }
14233 );
14234 let requires_open_provider_ids =
14235 request_requires_open_provider_ids(&shared, &request.request);
14236 let requires_child_environment_profile =
14237 request_requires_child_environment_profile(&shared, &request.request);
14238 let requires_session_bundle =
14239 request_requires_session_bundle(&shared, &request.request);
14240 let requires_history_context_pack =
14241 request_requires_history_context_pack(&shared, &request.request);
14242 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14243 ResponseEnvelope {
14244 request_id: request.request_id,
14245 result: Err(failure(
14246 NodeFailureCode::UnsupportedCapability,
14247 "open provider IDs were not negotiated",
14248 )),
14249 }
14250 } else if requires_child_environment_profile
14251 && !include_child_environment_profiles
14252 {
14253 ResponseEnvelope {
14254 request_id: request.request_id,
14255 result: Err(failure(
14256 NodeFailureCode::UnsupportedCapability,
14257 "child environment profiles were not negotiated",
14258 )),
14259 }
14260 } else if requires_session_bundle && !include_session_bundles {
14261 ResponseEnvelope {
14262 request_id: request.request_id,
14263 result: Err(failure(
14264 NodeFailureCode::UnsupportedCapability,
14265 "session bundles were not negotiated",
14266 )),
14267 }
14268 } else if requires_history_context_pack && !include_history_context_packs {
14269 ResponseEnvelope {
14270 request_id: request.request_id,
14271 result: Err(failure(
14272 NodeFailureCode::UnsupportedCapability,
14273 "history context packs were not negotiated",
14274 )),
14275 }
14276 } else if request_uses_unnegotiated_capability(
14277 &request.request,
14278 &selected_capabilities,
14279 )
14280 {
14281 ResponseEnvelope {
14282 request_id: request.request_id,
14283 result: Err(failure(
14284 NodeFailureCode::UnsupportedCapability,
14285 "request capability was not negotiated",
14286 )),
14287 }
14288 } else {
14289 process_request(&shared, connection_id, hello.role, request).await
14290 };
14291 #[cfg(feature = "fixture")]
14292 if probe_spawn_managed_worktree_v2 {
14293 shared
14294 .fixture_spawn_managed_worktree_v2_failure_probe
14295 .record(reply.result.as_ref().err().map(|failure| failure.code));
14296 }
14297 if !include_provider_runtime_status {
14298 clear_response_provider_runtime_status(&mut reply);
14299 }
14300 if !include_managed_worktrees {
14301 project_response_without_managed_worktrees(&mut reply);
14302 }
14303 if !include_open_provider_ids {
14304 project_response_legacy_provider_ids(&shared, &mut reply);
14305 }
14306 if !include_child_environment_profiles {
14307 project_response_without_child_environment_profile(&mut reply);
14308 }
14309 if !include_session_bundles {
14310 project_response_without_session_bundle(&mut reply);
14311 }
14312 project_response_history_for_wire(
14313 &mut reply,
14314 include_history_context_packs,
14315 );
14316 if !include_history_context_packs {
14317 project_response_without_context_pack(&mut reply);
14318 }
14319 if !include_agent_progress {
14320 project_response_without_agent_progress(&mut reply);
14321 }
14322 if !include_session_task_correlation {
14323 project_response_without_session_task_binding(&mut reply);
14324 }
14325 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14326 }
14327 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14328 event = event_rx.recv() => {
14329 match event {
14330 Ok(event) => queue_connection_event(
14331 &mut pending_events,
14332 event,
14333 discard_events_through,
14334 ),
14335 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14336 Err(broadcast::error::RecvError::Closed) => break,
14337 }
14338 }
14339 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14340 match events {
14341 Ok(events) => queue_connection_event_batch(
14342 &mut pending_events,
14343 &events,
14344 discard_events_through,
14345 ),
14346 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14347 Err(broadcast::error::RecvError::Closed) => break,
14348 }
14349 }
14350 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14351 match events {
14352 Ok(events) => queue_connection_event_batch(
14353 &mut pending_events,
14354 &events,
14355 discard_events_through,
14356 ),
14357 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14358 Err(broadcast::error::RecvError::Closed) => break,
14359 }
14360 }
14361 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14362 match events {
14363 Ok(events) => queue_connection_event_batch(
14364 &mut pending_events,
14365 &events,
14366 discard_events_through,
14367 ),
14368 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14369 Err(broadcast::error::RecvError::Closed) => break,
14370 }
14371 }
14372 _ = shared.shutdown_notify.notified() => break,
14373 }
14374 }
14375 Ok(())
14376}
14377
14378async fn receive_terminal_event_batch(
14379 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14380) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14381 match receiver {
14382 Some(receiver) => receiver.recv().await,
14383 None => std::future::pending().await,
14384 }
14385}
14386
14387fn terminal_event_subscription(
14388 shared: &NodeShared,
14389 enabled: bool,
14390) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14391 enabled.then(|| shared.terminal_event_tx.subscribe())
14392}
14393
14394fn agent_stream_event_subscription(
14398 shared: &NodeShared,
14399 enabled: bool,
14400) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14401 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14402}
14403
14404fn harness_mcp_proxy_event_subscription(
14409 shared: &NodeShared,
14410 enabled: bool,
14411) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14412 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14413}
14414
14415fn queue_connection_event(
14416 pending: &mut Vec<NodeEventEnvelope>,
14417 event: NodeEventEnvelope,
14418 discard_through: u64,
14419) {
14420 if event.sequence <= discard_through
14421 && !event.event.requires_harness_mcp_proxy_capability()
14422 {
14423 return;
14424 }
14425 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14426 if let Some(index) = pending.iter().position(|current| {
14427 matches!(
14428 ¤t.event,
14429 NodeEvent::TerminalFrame {
14430 address: current_address,
14431 ..
14432 } if current_address == address
14433 )
14434 }) {
14435 pending.remove(index);
14436 }
14437 }
14438 pending.push(event);
14439}
14440
14441fn project_event_without_harness_mcp_proxy(
14442 envelope: NodeEventEnvelope,
14443) -> Option<NodeEventEnvelope> {
14444 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14445}
14446
14447fn queue_connection_event_batch(
14448 pending: &mut Vec<NodeEventEnvelope>,
14449 events: &[NodeEventEnvelope],
14450 discard_through: u64,
14451) {
14452 for event in events {
14453 queue_connection_event(pending, event.clone(), discard_through);
14454 }
14455}
14456
14457fn apply_connection_resync_watermark(
14458 pending: &mut Vec<NodeEventEnvelope>,
14459 discard_through: &mut u64,
14460 marker_sequence: u64,
14461) {
14462 *discard_through = (*discard_through).max(marker_sequence);
14463 pending.retain(|event| event.sequence > *discard_through);
14464}
14465
14466fn connection_event_burst_len(pending_len: usize) -> usize {
14467 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14468}
14469
14470fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14471 let history = shared
14472 .history
14473 .lock()
14474 .unwrap_or_else(|poisoned| poisoned.into_inner());
14475 let sequence = history.last_sequence;
14476 let oldest_available_sequence = history.replay_floor_sequence;
14477 NodeEventEnvelope {
14478 sequence,
14479 event: NodeEvent::ResyncRequired {
14480 oldest_available_sequence,
14481 },
14482 }
14483}
14484
14485fn node_compatibility_support(
14486 shared: &NodeShared,
14487) -> Result<NodeCompatibilitySupport, NodeServerError> {
14488 let mut support = node_compatibility_support_for_manifest(
14489 &shared.provider_contracts,
14490 &shared.provider_adapter_contracts,
14491 )?;
14492 if shared
14493 .delivery_store
14494 .lock()
14495 .unwrap_or_else(|poisoned| poisoned.into_inner())
14496 .is_some()
14497 {
14498 support.capabilities.push(
14499 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14500 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14501 );
14502 }
14503 if shared.harness_mcp_registry.is_some() {
14504 support.capabilities.push(
14505 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14506 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14507 );
14508 }
14509 Ok(support)
14510}
14511
14512fn node_compatibility_support_for_manifest(
14513 provider_contracts: &[ProviderContractSupport],
14514 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14515) -> Result<NodeCompatibilitySupport, NodeServerError> {
14516 Ok(NodeCompatibilitySupport {
14517 build_stamp: BUILD_STAMP.to_owned(),
14518 capabilities: baseline_capabilities()?,
14519 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14520 path_semantics: platform::path_semantics(),
14521 local_transport: platform::local_transport(),
14522 state_schema: StateSchemaSupport {
14523 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14524 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525 },
14526 provider_contracts: provider_contracts.to_vec(),
14527 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14528 })
14529}
14530
14531fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14532 let capabilities = [
14533 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14534 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14535 NODE_REPOSITORY_PATH_CAPABILITY,
14536 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14537 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14538 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14539 NODE_GIT_READ_CAPABILITY,
14540 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14541 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14542 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14543 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14544 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14545 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14546 NODE_ACP_CONTROL_CAPABILITY,
14547 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14548 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14549 NODE_WORKTREE_SELECTION_CAPABILITY,
14550 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14551 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14552 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14553 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14554 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14555 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14556 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14557 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14558 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14559 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14560 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14561 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14562 ]
14563 .into_iter()
14564 .map(|capability| {
14565 CapabilityId::new(capability)
14566 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14567 })
14568 .collect::<Result<Vec<_>, _>>()?;
14569 #[cfg(windows)]
14570 let capabilities = {
14571 let mut capabilities = capabilities;
14572 capabilities.push(
14573 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14574 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14575 );
14576 capabilities
14577 };
14578 Ok(capabilities)
14579}
14580
14581fn request_uses_unnegotiated_capability(
14582 request: &NodeRequest,
14583 selected_capabilities: &[CapabilityId],
14584) -> bool {
14585 let selected = |required: &str| {
14586 selected_capabilities
14587 .iter()
14588 .any(|capability| capability.as_str() == required)
14589 };
14590 request
14591 .required_capability()
14592 .is_some_and(|required| !selected(required))
14593 || (request.requires_worktree_selection_capability()
14594 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14595 || (request.requires_spawn_spec_defaults_overrides_capability()
14596 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14597 || (request.requires_spawn_profile_revision_capability()
14598 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14599 || (request.requires_child_environment_profile_capability()
14600 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14601 || (request.requires_session_bundle_materialization_capability()
14602 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14603 || (request.requires_history_context_pack_capability()
14604 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14605}
14606
14607fn snapshot_for_wire(
14608 shared: &NodeShared,
14609 include_provider_runtime_status: bool,
14610 include_open_provider_ids: bool,
14611 include_spawn_profiles: bool,
14612 include_managed_worktrees: bool,
14613 include_child_environment_profiles: bool,
14614 include_session_bundles: bool,
14615 include_history_context_packs: bool,
14616 include_agent_progress: bool,
14617 include_session_task_correlation: bool,
14618) -> NodeSnapshot {
14619 let mut snapshot = shared.snapshot();
14620 if !include_provider_runtime_status {
14621 snapshot.provider_runtime_statuses.clear();
14622 }
14623 if !include_open_provider_ids {
14624 project_snapshot_legacy_provider_ids(&mut snapshot);
14625 }
14626 if !include_managed_worktrees {
14627 snapshot.managed_worktrees.clear();
14628 for workspace in &mut snapshot.workspaces {
14629 workspace.managed_worktree_profiles = None;
14630 }
14631 }
14632 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14633 if !include_spawn_profiles {
14634 inventory.spawn_profiles = None;
14635 }
14636 if !include_session_bundles {
14637 inventory.bundles = None;
14638 }
14639 if inventory.spawn_profiles.is_none()
14640 && inventory.bundles.is_none()
14641 && inventory.network_allowlists.is_none()
14642 {
14643 snapshot.launch_inventory = None;
14644 }
14645 }
14646 if !include_child_environment_profiles {
14647 clear_snapshot_child_environment_profiles(&mut snapshot);
14648 }
14649 if !include_session_bundles {
14650 clear_snapshot_session_bundles(&mut snapshot);
14651 }
14652 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14653 if !include_history_context_packs {
14654 clear_snapshot_context_packs(&mut snapshot);
14655 }
14656 if !include_agent_progress {
14657 snapshot.agent_progress.clear();
14658 }
14659 if !include_session_task_correlation {
14660 clear_snapshot_session_task_bindings(&mut snapshot);
14661 }
14662 snapshot
14663}
14664
14665fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14666 for record in &mut snapshot.session_records {
14667 record.task_binding = None;
14668 }
14669}
14670
14671fn project_event_without_session_task_binding(
14672 mut envelope: NodeEventEnvelope,
14673) -> NodeEventEnvelope {
14674 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14675 record.task_binding = None;
14676 }
14677 envelope
14678}
14679
14680fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14681 let Ok(response) = reply.result.as_mut() else { return };
14682 match response {
14683 NodeResponse::Snapshot { snapshot, .. } => {
14684 clear_snapshot_session_task_bindings(snapshot);
14685 }
14686 NodeResponse::Resync { snapshot, events, .. } => {
14687 clear_snapshot_session_task_bindings(snapshot);
14688 for event in events {
14689 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14690 record.task_binding = None;
14691 }
14692 }
14693 }
14694 NodeResponse::SessionRecordUpdated { record }
14695 | NodeResponse::ProviderSessionIndexed { record }
14696 | NodeResponse::NativeSessionIndexed { record, .. }
14697 | NodeResponse::SessionRecordResumed { record, .. } => {
14698 record.task_binding = None;
14699 }
14700 _ => {}
14701 }
14702}
14703
14704fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14705 let Ok(response) = reply.result.as_mut() else { return };
14706 match response {
14707 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14708 snapshot.agent_progress.clear();
14709 }
14710 _ => {}
14711 }
14712}
14713
14714fn project_event_without_managed_worktrees(
14715 mut envelope: NodeEventEnvelope,
14716) -> Option<NodeEventEnvelope> {
14717 if matches!(
14718 envelope.event,
14719 NodeEvent::ManagedWorktreeUpserted { .. }
14720 | NodeEvent::ManagedWorktreeRemoved { .. }
14721 ) {
14722 return None;
14723 }
14724 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14725 workspace.managed_worktree_profiles = None;
14726 }
14727 Some(envelope)
14728}
14729
14730fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14731 if matches!(
14732 reply.result,
14733 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14734 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14735 ) {
14736 reply.result = Err(failure(
14737 NodeFailureCode::UnsupportedCapability,
14738 "managed worktree capability was not negotiated",
14739 ));
14740 return;
14741 }
14742 let Ok(response) = reply.result.as_mut() else { return };
14743 match response {
14744 NodeResponse::Snapshot { snapshot, .. } => {
14745 snapshot.managed_worktrees.clear();
14746 for workspace in &mut snapshot.workspaces {
14747 workspace.managed_worktree_profiles = None;
14748 }
14749 }
14750 NodeResponse::Resync { snapshot, events, .. } => {
14751 snapshot.managed_worktrees.clear();
14752 for workspace in &mut snapshot.workspaces {
14753 workspace.managed_worktree_profiles = None;
14754 }
14755 *events = std::mem::take(events)
14756 .into_iter()
14757 .filter_map(project_event_without_managed_worktrees)
14758 .collect();
14759 }
14760 NodeResponse::WorkspaceRegistered { workspace }
14761 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14762 | NodeResponse::WorktreeCreated { workspace, .. } => {
14763 workspace.managed_worktree_profiles = None;
14764 }
14765 NodeResponse::WorkspaceInspected { inspection } => {
14766 inspection.git.managed_worktree = None;
14767 }
14768 _ => {}
14769 }
14770}
14771
14772fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14773 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14774 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14775 });
14776 if includes_open_provider_ids {
14777 return;
14778 }
14779 compatibility
14780 .provider_contracts
14781 .retain(|contract| provider_id_is_legacy(&contract.provider));
14782 compatibility
14783 .provider_adapter_contracts
14784 .retain(|contract| provider_id_is_legacy(&contract.provider));
14785}
14786
14787fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14788 if let Some(spec) = match request {
14789 NodeRequest::SpawnSpec { spec }
14790 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14791 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14792 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14793 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14794 _ => None,
14795 } {
14796 return shared
14797 .resolve_spawn_spec(spec)
14798 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14799 }
14800 request_requires_open_provider_ids_with(
14801 request,
14802 |session| shared.validate_address(session).ok(),
14803 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14804 )
14805}
14806
14807fn request_requires_child_environment_profile(
14808 shared: &NodeShared,
14809 request: &NodeRequest,
14810) -> bool {
14811 if let Some(spec) = match request {
14812 NodeRequest::SpawnSpec { spec }
14813 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14814 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14815 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14816 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14817 _ => None,
14818 } {
14819 return shared
14820 .resolve_spawn_spec(spec)
14821 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14822 }
14823 match request {
14824 NodeRequest::Resume { session, .. }
14825 | NodeRequest::Prompt { session, .. }
14826 | NodeRequest::Paste { session, .. }
14827 | NodeRequest::Input { session, .. }
14828 | NodeRequest::TerminalBytes { session, .. }
14829 | NodeRequest::TerminalControl { session, .. }
14830 | NodeRequest::Resize { session, .. }
14831 | NodeRequest::Interrupt { session }
14832 | NodeRequest::Stop { session, .. }
14833 | NodeRequest::Remove { session }
14834 | NodeRequest::ResolveInteraction { session, .. }
14835 | NodeRequest::SetSessionMode { session, .. }
14836 | NodeRequest::SetSessionConfigOption { session, .. }
14837 | NodeRequest::SetSessionModel { session, .. }
14838 | NodeRequest::DiscoverHistory { session, .. }
14839 | NodeRequest::LoadHistory { session, .. }
14840 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14841 | NodeRequest::ExportContextPack { session } => shared
14842 .session_bindings
14843 .lock()
14844 .unwrap_or_else(|poisoned| poisoned.into_inner())
14845 .get(&session.session.instance_id)
14846 .filter(|binding| {
14847 binding.workspace_id == session.workspace_id
14848 && binding.generation == session.session.generation
14849 })
14850 .map_or(true, |binding| binding.environment_profile.is_some()),
14851 NodeRequest::RenameSessionRecord { record_id, .. }
14852 | NodeRequest::SetSessionTask { record_id, .. }
14853 | NodeRequest::ResumeSessionRecord { record_id, .. }
14854 | NodeRequest::ForgetSessionRecord { record_id } => shared
14855 .record(record_id)
14856 .map_or(true, |record| record.environment_profile.is_some()),
14857 NodeRequest::ArmHarnessMcpReservation { .. }
14858 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14859 | NodeRequest::ActivateHarnessMcpReservation { .. }
14860 | NodeRequest::AbortHarnessMcpReservation { .. }
14861 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14862 | NodeRequest::RejectHarnessMcpCall { .. }
14863 | NodeRequest::Snapshot
14864 | NodeRequest::Resync { .. }
14865 | NodeRequest::BeginDeliveryStage { .. }
14866 | NodeRequest::PutDeliveryBlobChunk { .. }
14867 | NodeRequest::CommitDeliveryStage { .. }
14868 | NodeRequest::AbortDeliveryStage { .. }
14869 | NodeRequest::BrowseHostDirectories { .. }
14870 | NodeRequest::InspectWorkspace { .. }
14871 | NodeRequest::ReadWorkspaceFile { .. }
14872 | NodeRequest::WriteWorkspaceFile { .. }
14873 | NodeRequest::CreateWorkspaceFile { .. }
14874 | NodeRequest::CreateWorkspaceDirectory { .. }
14875 | NodeRequest::ReadGitHistory { .. }
14876 | NodeRequest::ReadGitDiff { .. }
14877 | NodeRequest::AcquireController { .. }
14878 | NodeRequest::ReleaseController
14879 | NodeRequest::Spawn { .. }
14880 | NodeRequest::SpawnWithOptions { .. }
14881 | NodeRequest::SpawnSpec { .. }
14882 | NodeRequest::RegisterWorkspace { .. }
14883 | NodeRequest::CreateStandaloneWorkspace { .. }
14884 | NodeRequest::UnregisterWorkspace { .. }
14885 | NodeRequest::CreateWorktree { .. }
14886 | NodeRequest::RemoveWorktree { .. }
14887 | NodeRequest::SpawnManagedWorktree { .. }
14888 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14889 | NodeRequest::CleanupManagedWorktree { .. }
14890 | NodeRequest::IndexProviderSession { .. }
14891 | NodeRequest::IndexNativeSession { .. }
14892 | NodeRequest::CatalogNativeSessions { .. }
14893 | NodeRequest::PageNativeSessions { .. }
14894 | NodeRequest::PreviewNativeSession { .. }
14895 | NodeRequest::PreviewSessionRecord { .. }
14896 | NodeRequest::ForgetContextPack { .. }
14897 | NodeRequest::ResolveDurableContextPack { .. }
14898 | NodeRequest::ReadContextPack { .. }
14899 | NodeRequest::Shutdown => false,
14900 }
14901}
14902
14903fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14904 if let Some(spec) = match request {
14905 NodeRequest::SpawnSpec { spec }
14906 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14907 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14908 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14909 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14910 _ => None,
14911 } {
14912 return shared
14913 .resolve_spawn_spec(spec)
14914 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14915 }
14916 match request {
14917 NodeRequest::Resume { session, .. }
14918 | NodeRequest::Prompt { session, .. }
14919 | NodeRequest::Paste { session, .. }
14920 | NodeRequest::Input { session, .. }
14921 | NodeRequest::TerminalBytes { session, .. }
14922 | NodeRequest::TerminalControl { session, .. }
14923 | NodeRequest::Resize { session, .. }
14924 | NodeRequest::Interrupt { session }
14925 | NodeRequest::Stop { session, .. }
14926 | NodeRequest::Remove { session }
14927 | NodeRequest::ResolveInteraction { session, .. }
14928 | NodeRequest::SetSessionMode { session, .. }
14929 | NodeRequest::SetSessionConfigOption { session, .. }
14930 | NodeRequest::SetSessionModel { session, .. }
14931 | NodeRequest::DiscoverHistory { session, .. }
14932 | NodeRequest::LoadHistory { session, .. }
14933 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14934 | NodeRequest::ExportContextPack { session } => shared
14935 .session_bindings
14936 .lock()
14937 .unwrap_or_else(|poisoned| poisoned.into_inner())
14938 .get(&session.session.instance_id)
14939 .filter(|binding| {
14940 binding.workspace_id == session.workspace_id
14941 && binding.generation == session.session.generation
14942 })
14943 .map_or(true, |binding| binding.bundle.is_some()),
14944 NodeRequest::RenameSessionRecord { record_id, .. }
14945 | NodeRequest::SetSessionTask { record_id, .. }
14946 | NodeRequest::ResumeSessionRecord { record_id, .. }
14947 | NodeRequest::ForgetSessionRecord { record_id } => shared
14948 .record(record_id)
14949 .map_or(true, |record| record.bundle.is_some()),
14950 NodeRequest::ArmHarnessMcpReservation { .. }
14951 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14952 | NodeRequest::ActivateHarnessMcpReservation { .. }
14953 | NodeRequest::AbortHarnessMcpReservation { .. }
14954 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14955 | NodeRequest::RejectHarnessMcpCall { .. }
14956 | NodeRequest::Snapshot
14957 | NodeRequest::Resync { .. }
14958 | NodeRequest::BeginDeliveryStage { .. }
14959 | NodeRequest::PutDeliveryBlobChunk { .. }
14960 | NodeRequest::CommitDeliveryStage { .. }
14961 | NodeRequest::AbortDeliveryStage { .. }
14962 | NodeRequest::BrowseHostDirectories { .. }
14963 | NodeRequest::InspectWorkspace { .. }
14964 | NodeRequest::ReadWorkspaceFile { .. }
14965 | NodeRequest::WriteWorkspaceFile { .. }
14966 | NodeRequest::CreateWorkspaceFile { .. }
14967 | NodeRequest::CreateWorkspaceDirectory { .. }
14968 | NodeRequest::ReadGitHistory { .. }
14969 | NodeRequest::ReadGitDiff { .. }
14970 | NodeRequest::AcquireController { .. }
14971 | NodeRequest::ReleaseController
14972 | NodeRequest::Spawn { .. }
14973 | NodeRequest::SpawnWithOptions { .. }
14974 | NodeRequest::SpawnSpec { .. }
14975 | NodeRequest::RegisterWorkspace { .. }
14976 | NodeRequest::CreateStandaloneWorkspace { .. }
14977 | NodeRequest::UnregisterWorkspace { .. }
14978 | NodeRequest::CreateWorktree { .. }
14979 | NodeRequest::RemoveWorktree { .. }
14980 | NodeRequest::SpawnManagedWorktree { .. }
14981 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14982 | NodeRequest::CleanupManagedWorktree { .. }
14983 | NodeRequest::IndexProviderSession { .. }
14984 | NodeRequest::IndexNativeSession { .. }
14985 | NodeRequest::CatalogNativeSessions { .. }
14986 | NodeRequest::PageNativeSessions { .. }
14987 | NodeRequest::PreviewNativeSession { .. }
14988 | NodeRequest::PreviewSessionRecord { .. }
14989 | NodeRequest::ForgetContextPack { .. }
14990 | NodeRequest::ResolveDurableContextPack { .. }
14991 | NodeRequest::ReadContextPack { .. }
14992 | NodeRequest::Shutdown => false,
14993 }
14994}
14995
14996fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14997 if request.requires_history_context_pack_capability() {
14998 return true;
14999 }
15000 if let Some(spec) = match request {
15001 NodeRequest::SpawnSpec { spec }
15002 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15003 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15004 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15005 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15006 _ => None,
15007 } {
15008 return shared
15009 .resolve_spawn_spec(spec)
15010 .map_or(true, |resolved| resolved.context_id.is_some());
15011 }
15012 match request {
15013 NodeRequest::Resume { session, .. }
15014 | NodeRequest::Prompt { session, .. }
15015 | NodeRequest::Paste { session, .. }
15016 | NodeRequest::Input { session, .. }
15017 | NodeRequest::TerminalBytes { session, .. }
15018 | NodeRequest::TerminalControl { session, .. }
15019 | NodeRequest::Resize { session, .. }
15020 | NodeRequest::Interrupt { session }
15021 | NodeRequest::Stop { session, .. }
15022 | NodeRequest::Remove { session }
15023 | NodeRequest::ResolveInteraction { session, .. }
15024 | NodeRequest::SetSessionMode { session, .. }
15025 | NodeRequest::SetSessionConfigOption { session, .. }
15026 | NodeRequest::SetSessionModel { session, .. } => shared
15027 .session_bindings
15028 .lock()
15029 .unwrap_or_else(|poisoned| poisoned.into_inner())
15030 .get(&session.session.instance_id)
15031 .filter(|binding| {
15032 binding.workspace_id == session.workspace_id
15033 && binding.generation == session.session.generation
15034 })
15035 .map_or(true, |binding| binding.context.is_some()),
15036 NodeRequest::RenameSessionRecord { record_id, .. }
15037 | NodeRequest::SetSessionTask { record_id, .. }
15038 | NodeRequest::ResumeSessionRecord { record_id, .. }
15039 | NodeRequest::ForgetSessionRecord { record_id } => shared
15040 .record(record_id)
15041 .map_or(true, |record| record.context.is_some()),
15042 NodeRequest::ArmHarnessMcpReservation { .. }
15043 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15044 | NodeRequest::ActivateHarnessMcpReservation { .. }
15045 | NodeRequest::AbortHarnessMcpReservation { .. }
15046 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15047 | NodeRequest::RejectHarnessMcpCall { .. }
15048 | NodeRequest::Snapshot
15049 | NodeRequest::Resync { .. }
15050 | NodeRequest::BeginDeliveryStage { .. }
15051 | NodeRequest::PutDeliveryBlobChunk { .. }
15052 | NodeRequest::CommitDeliveryStage { .. }
15053 | NodeRequest::AbortDeliveryStage { .. }
15054 | NodeRequest::BrowseHostDirectories { .. }
15055 | NodeRequest::InspectWorkspace { .. }
15056 | NodeRequest::ReadWorkspaceFile { .. }
15057 | NodeRequest::WriteWorkspaceFile { .. }
15058 | NodeRequest::CreateWorkspaceFile { .. }
15059 | NodeRequest::CreateWorkspaceDirectory { .. }
15060 | NodeRequest::ReadGitHistory { .. }
15061 | NodeRequest::ReadGitDiff { .. }
15062 | NodeRequest::AcquireController { .. }
15063 | NodeRequest::ReleaseController
15064 | NodeRequest::Spawn { .. }
15065 | NodeRequest::SpawnWithOptions { .. }
15066 | NodeRequest::SpawnSpec { .. }
15067 | NodeRequest::RegisterWorkspace { .. }
15068 | NodeRequest::CreateStandaloneWorkspace { .. }
15069 | NodeRequest::UnregisterWorkspace { .. }
15070 | NodeRequest::CreateWorktree { .. }
15071 | NodeRequest::RemoveWorktree { .. }
15072 | NodeRequest::SpawnManagedWorktree { .. }
15073 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15074 | NodeRequest::CleanupManagedWorktree { .. }
15075 | NodeRequest::IndexProviderSession { .. }
15076 | NodeRequest::IndexNativeSession { .. }
15077 | NodeRequest::CatalogNativeSessions { .. }
15078 | NodeRequest::PageNativeSessions { .. }
15079 | NodeRequest::PreviewNativeSession { .. }
15080 | NodeRequest::PreviewSessionRecord { .. }
15081 | NodeRequest::DiscoverHistory { .. }
15082 | NodeRequest::LoadHistory { .. }
15083 | NodeRequest::ExportContextPackForSessionRecord { .. }
15084 | NodeRequest::ExportContextPack { .. }
15085 | NodeRequest::ForgetContextPack { .. }
15086 | NodeRequest::ResolveDurableContextPack { .. }
15087 | NodeRequest::ReadContextPack { .. }
15088 | NodeRequest::Shutdown => false,
15089 }
15090}
15091
15092fn request_requires_open_provider_ids_with(
15093 request: &NodeRequest,
15094 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15095 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15096) -> bool {
15097 match request {
15098 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15099 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15100 NodeRequest::CatalogNativeSessions { route, .. }
15101 | NodeRequest::PageNativeSessions { route, .. } => {
15102 !provider_id_is_legacy(&route.provider)
15103 }
15104 NodeRequest::PreviewNativeSession { selection, .. }
15105 | NodeRequest::IndexNativeSession { selection, .. } => {
15106 !provider_id_is_legacy(&selection.route.provider)
15107 }
15108 NodeRequest::SpawnSpec { .. }
15109 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15110 NodeRequest::SpawnManagedWorktree { .. }
15111 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15112 NodeRequest::Resume { session, .. }
15113 | NodeRequest::Prompt { session, .. }
15114 | NodeRequest::Paste { session, .. }
15115 | NodeRequest::Input { session, .. }
15116 | NodeRequest::TerminalBytes { session, .. }
15117 | NodeRequest::TerminalControl { session, .. }
15118 | NodeRequest::Resize { session, .. }
15119 | NodeRequest::Interrupt { session }
15120 | NodeRequest::Stop { session, .. }
15121 | NodeRequest::Remove { session }
15122 | NodeRequest::ResolveInteraction { session, .. }
15123 | NodeRequest::SetSessionMode { session, .. }
15124 | NodeRequest::SetSessionConfigOption { session, .. }
15125 | NodeRequest::SetSessionModel { session, .. }
15126 | NodeRequest::DiscoverHistory { session, .. }
15127 | NodeRequest::LoadHistory { session, .. }
15128 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15129 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15130 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15131 NodeRequest::RenameSessionRecord { record_id, .. }
15132 | NodeRequest::SetSessionTask { record_id, .. }
15133 | NodeRequest::ResumeSessionRecord { record_id, .. }
15134 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15135 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15136 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15137 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15138 NodeRequest::ArmHarnessMcpReservation { .. }
15139 | NodeRequest::ActivateHarnessMcpReservation { .. }
15140 | NodeRequest::AbortHarnessMcpReservation { .. }
15141 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15142 | NodeRequest::RejectHarnessMcpCall { .. }
15143 | NodeRequest::Snapshot
15144 | NodeRequest::Resync { .. }
15145 | NodeRequest::BeginDeliveryStage { .. }
15146 | NodeRequest::PutDeliveryBlobChunk { .. }
15147 | NodeRequest::CommitDeliveryStage { .. }
15148 | NodeRequest::AbortDeliveryStage { .. }
15149 | NodeRequest::BrowseHostDirectories { .. }
15150 | NodeRequest::InspectWorkspace { .. }
15151 | NodeRequest::ReadWorkspaceFile { .. }
15152 | NodeRequest::WriteWorkspaceFile { .. }
15153 | NodeRequest::CreateWorkspaceFile { .. }
15154 | NodeRequest::CreateWorkspaceDirectory { .. }
15155 | NodeRequest::ReadGitHistory { .. }
15156 | NodeRequest::ReadGitDiff { .. }
15157 | NodeRequest::AcquireController { .. }
15158 | NodeRequest::ReleaseController
15159 | NodeRequest::RegisterWorkspace { .. }
15160 | NodeRequest::CreateStandaloneWorkspace { .. }
15161 | NodeRequest::UnregisterWorkspace { .. }
15162 | NodeRequest::CreateWorktree { .. }
15163 | NodeRequest::RemoveWorktree { .. }
15164 | NodeRequest::CleanupManagedWorktree { .. }
15165 | NodeRequest::Shutdown => false,
15166 NodeRequest::ForgetContextPack { .. } => true,
15167 NodeRequest::ResolveDurableContextPack { .. }
15173 | NodeRequest::ReadContextPack { .. } => true,
15174 }
15175}
15176
15177fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15178 snapshot.enabled_providers.retain(provider_id_is_legacy);
15179 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15180 snapshot
15181 .provider_runtime_statuses
15182 .as_slice()
15183 .iter()
15184 .filter(|status| provider_id_is_legacy(status.provider()))
15185 .cloned(),
15186 )
15187 .expect("a filtered provider runtime projection remains valid");
15188 snapshot
15189 .session_records
15190 .retain(|record| provider_id_is_legacy(&record.provider));
15191 for workspace in &mut snapshot.workspaces {
15192 workspace
15193 .sessions
15194 .retain(|session| provider_id_is_legacy(&session.agent_id));
15195 }
15196}
15197
15198fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15199 for record in &mut snapshot.session_records {
15200 record.environment_profile = None;
15201 }
15202 if let Some(profiles) = snapshot
15203 .launch_inventory
15204 .as_mut()
15205 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15206 {
15207 for profile in profiles {
15208 profile.environment_profile = None;
15209 }
15210 }
15211}
15212
15213fn project_event_without_child_environment_profile(
15214 mut envelope: NodeEventEnvelope,
15215) -> NodeEventEnvelope {
15216 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15217 record.environment_profile = None;
15218 }
15219 envelope
15220}
15221
15222fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15223 let contains_environment_profile = match reply.result.as_ref() {
15224 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15225 receipt.environment_profile.is_some()
15226 }
15227 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15228 receipt.spawn.environment_profile.is_some()
15229 }
15230 _ => false,
15231 };
15232 if contains_environment_profile {
15233 reply.result = Err(failure(
15234 NodeFailureCode::UnsupportedCapability,
15235 "child environment profiles were not negotiated",
15236 ));
15237 return;
15238 }
15239 let Ok(response) = reply.result.as_mut() else {
15240 return;
15241 };
15242 match response {
15243 NodeResponse::Snapshot { snapshot, .. } => {
15244 clear_snapshot_child_environment_profiles(snapshot);
15245 }
15246 NodeResponse::Resync { snapshot, events, .. } => {
15247 clear_snapshot_child_environment_profiles(snapshot);
15248 for event in events {
15249 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15250 record.environment_profile = None;
15251 }
15252 }
15253 }
15254 NodeResponse::SessionRecordUpdated { record }
15255 | NodeResponse::ProviderSessionIndexed { record }
15256 | NodeResponse::NativeSessionIndexed { record, .. }
15257 | NodeResponse::SessionRecordResumed { record, .. } => {
15258 record.environment_profile = None;
15259 }
15260 NodeResponse::Armed { .. }
15261 | NodeResponse::Spawned { .. }
15262 | NodeResponse::Activated { .. }
15263 | NodeResponse::Aborted { .. }
15264 | NodeResponse::ReplyChunkAccepted { .. }
15265 | NodeResponse::CallRejected { .. }
15266 | NodeResponse::DeliveryStageBegun { .. }
15267 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15268 | NodeResponse::DeliveryCommitted { .. }
15269 | NodeResponse::DeliveryStageAborted { .. }
15270 | NodeResponse::WorkspaceInspected { .. }
15271 | NodeResponse::HostDirectoriesBrowsed { .. }
15272 | NodeResponse::WorkspaceFileRead { .. }
15273 | NodeResponse::WorkspaceFileWritten { .. }
15274 | NodeResponse::WorkspaceFileCreated { .. }
15275 | NodeResponse::WorkspaceDirectoryCreated { .. }
15276 | NodeResponse::GitHistoryRead { .. }
15277 | NodeResponse::GitDiffRead { .. }
15278 | NodeResponse::Controller { .. }
15279 | NodeResponse::SpawnAccepted { .. }
15280 | NodeResponse::SpawnSpecAccepted { .. }
15281 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15282 | NodeResponse::ManagedWorktreeCleanup { .. }
15283 | NodeResponse::NativeSessionsCataloged { .. }
15284 | NodeResponse::NativeSessionsPaged { .. }
15285 | NodeResponse::NativeSessionPreviewed { .. }
15286 | NodeResponse::SessionRecordPreviewed { .. }
15287 | NodeResponse::HistoryDiscovered { .. }
15288 | NodeResponse::HistoryLoaded { .. }
15289 | NodeResponse::ContextPackForSessionRecordExported { .. }
15290 | NodeResponse::ContextPackExported { .. }
15291 | NodeResponse::ContextPackForgotten { .. }
15292 | NodeResponse::DurableContextPackResolved { .. }
15293 | NodeResponse::ContextPackBytesRead { .. }
15294 | NodeResponse::SessionRecordForgotten { .. }
15295 | NodeResponse::WorkspaceRegistered { .. }
15296 | NodeResponse::StandaloneWorkspaceCreated { .. }
15297 | NodeResponse::WorkspaceUnregistered { .. }
15298 | NodeResponse::WorktreeCreated { .. }
15299 | NodeResponse::WorktreeRemoved { .. }
15300 | NodeResponse::Accepted
15301 | NodeResponse::ShuttingDown => {}
15302 }
15303}
15304
15305fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15306 for record in &mut snapshot.session_records {
15307 record.bundle = None;
15308 }
15309}
15310
15311fn project_event_without_session_bundle(
15312 mut envelope: NodeEventEnvelope,
15313) -> NodeEventEnvelope {
15314 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15315 record.bundle = None;
15316 }
15317 envelope
15318}
15319
15320fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15321 let contains_bundle = match reply.result.as_ref() {
15322 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15323 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15324 receipt.spawn.bundle.is_some()
15325 }
15326 _ => false,
15327 };
15328 if contains_bundle {
15329 reply.result = Err(failure(
15330 NodeFailureCode::UnsupportedCapability,
15331 "session bundles were not negotiated",
15332 ));
15333 return;
15334 }
15335 let Ok(response) = reply.result.as_mut() else {
15336 return;
15337 };
15338 match response {
15339 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15340 NodeResponse::Resync { snapshot, events, .. } => {
15341 clear_snapshot_session_bundles(snapshot);
15342 for event in events {
15343 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15344 record.bundle = None;
15345 }
15346 }
15347 }
15348 NodeResponse::SessionRecordUpdated { record }
15349 | NodeResponse::ProviderSessionIndexed { record }
15350 | NodeResponse::NativeSessionIndexed { record, .. }
15351 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15352 NodeResponse::Armed { .. }
15353 | NodeResponse::Spawned { .. }
15354 | NodeResponse::Activated { .. }
15355 | NodeResponse::Aborted { .. }
15356 | NodeResponse::ReplyChunkAccepted { .. }
15357 | NodeResponse::CallRejected { .. }
15358 | NodeResponse::DeliveryStageBegun { .. }
15359 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15360 | NodeResponse::DeliveryCommitted { .. }
15361 | NodeResponse::DeliveryStageAborted { .. }
15362 | NodeResponse::WorkspaceInspected { .. }
15363 | NodeResponse::HostDirectoriesBrowsed { .. }
15364 | NodeResponse::WorkspaceFileRead { .. }
15365 | NodeResponse::WorkspaceFileWritten { .. }
15366 | NodeResponse::WorkspaceFileCreated { .. }
15367 | NodeResponse::WorkspaceDirectoryCreated { .. }
15368 | NodeResponse::GitHistoryRead { .. }
15369 | NodeResponse::GitDiffRead { .. }
15370 | NodeResponse::Controller { .. }
15371 | NodeResponse::SpawnAccepted { .. }
15372 | NodeResponse::SpawnSpecAccepted { .. }
15373 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15374 | NodeResponse::ManagedWorktreeCleanup { .. }
15375 | NodeResponse::NativeSessionsCataloged { .. }
15376 | NodeResponse::NativeSessionsPaged { .. }
15377 | NodeResponse::NativeSessionPreviewed { .. }
15378 | NodeResponse::SessionRecordPreviewed { .. }
15379 | NodeResponse::HistoryDiscovered { .. }
15380 | NodeResponse::HistoryLoaded { .. }
15381 | NodeResponse::ContextPackForSessionRecordExported { .. }
15382 | NodeResponse::ContextPackExported { .. }
15383 | NodeResponse::ContextPackForgotten { .. }
15384 | NodeResponse::DurableContextPackResolved { .. }
15385 | NodeResponse::ContextPackBytesRead { .. }
15386 | NodeResponse::SessionRecordForgotten { .. }
15387 | NodeResponse::WorkspaceRegistered { .. }
15388 | NodeResponse::StandaloneWorkspaceCreated { .. }
15389 | NodeResponse::WorkspaceUnregistered { .. }
15390 | NodeResponse::WorktreeCreated { .. }
15391 | NodeResponse::WorktreeRemoved { .. }
15392 | NodeResponse::Accepted
15393 | NodeResponse::ShuttingDown => {}
15394 }
15395}
15396
15397fn project_snapshot_history_for_wire(
15398 snapshot: &mut NodeSnapshot,
15399 include_history_metadata: bool,
15400) {
15401 for workspace in &mut snapshot.workspaces {
15402 for session in &mut workspace.sessions {
15403 if include_history_metadata {
15404 session.history.loaded = None;
15405 } else {
15406 session.history = Default::default();
15407 }
15408 }
15409 }
15410}
15411
15412fn project_event_history_for_wire(
15413 mut envelope: NodeEventEnvelope,
15414 include_history_metadata: bool,
15415) -> NodeEventEnvelope {
15416 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15417 for session in &mut workspace.sessions {
15418 if include_history_metadata {
15419 session.history.loaded = None;
15420 } else {
15421 session.history = Default::default();
15422 }
15423 }
15424 }
15425 envelope
15426}
15427
15428fn project_response_history_for_wire(
15429 reply: &mut ResponseEnvelope,
15430 include_history_metadata: bool,
15431) {
15432 let Ok(response) = reply.result.as_mut() else {
15433 return;
15434 };
15435 match response {
15436 NodeResponse::Snapshot { snapshot, .. } => {
15437 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15438 }
15439 NodeResponse::Resync {
15440 snapshot, events, ..
15441 } => {
15442 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15443 for event in events {
15444 *event = project_event_history_for_wire(
15445 event.clone(),
15446 include_history_metadata,
15447 );
15448 }
15449 }
15450 NodeResponse::WorkspaceRegistered { workspace }
15451 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15452 | NodeResponse::WorktreeCreated { workspace, .. } => {
15453 for session in &mut workspace.sessions {
15454 if include_history_metadata {
15455 session.history.loaded = None;
15456 } else {
15457 session.history = Default::default();
15458 }
15459 }
15460 }
15461 _ => {}
15462 }
15463}
15464
15465fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15466 for record in &mut snapshot.session_records {
15467 record.context_id = None;
15468 record.context = None;
15469 record.exported_context = None;
15470 }
15471}
15472
15473fn project_event_without_context_pack(
15474 mut envelope: NodeEventEnvelope,
15475) -> NodeEventEnvelope {
15476 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15477 record.context_id = None;
15478 record.context = None;
15479 record.exported_context = None;
15480 }
15481 envelope
15482}
15483
15484fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15485 let contains_context = match reply.result.as_ref() {
15486 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15487 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15488 receipt.spawn.context.is_some()
15489 }
15490 Ok(NodeResponse::HistoryDiscovered { .. })
15491 | Ok(NodeResponse::HistoryLoaded { .. })
15492 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15493 | Ok(NodeResponse::ContextPackExported { .. })
15494 | Ok(NodeResponse::ContextPackForgotten { .. })
15495 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15496 _ => false,
15497 };
15498 if contains_context {
15499 reply.result = Err(failure(
15500 NodeFailureCode::UnsupportedCapability,
15501 "history context packs were not negotiated",
15502 ));
15503 return;
15504 }
15505 let Ok(response) = reply.result.as_mut() else {
15506 return;
15507 };
15508 match response {
15509 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15510 NodeResponse::Resync {
15511 snapshot, events, ..
15512 } => {
15513 clear_snapshot_context_packs(snapshot);
15514 for event in events {
15515 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15516 record.context_id = None;
15517 record.context = None;
15518 record.exported_context = None;
15519 }
15520 }
15521 }
15522 NodeResponse::SessionRecordUpdated { record }
15523 | NodeResponse::ProviderSessionIndexed { record }
15524 | NodeResponse::NativeSessionIndexed { record, .. }
15525 | NodeResponse::SessionRecordResumed { record, .. } => {
15526 record.context_id = None;
15527 record.context = None;
15528 record.exported_context = None;
15529 }
15530 _ => {}
15531 }
15532}
15533
15534fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15535 let contains_open_record = match reply.result.as_ref() {
15536 Ok(NodeResponse::SessionRecordUpdated { record })
15537 | Ok(NodeResponse::ProviderSessionIndexed { record })
15538 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15539 !provider_id_is_legacy(&record.provider)
15540 }
15541 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15542 !provider_id_is_legacy(&selection.route.provider)
15543 || !provider_id_is_legacy(&record.provider)
15544 }
15545 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15546 !provider_id_is_legacy(&receipt.provider)
15547 }
15548 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15549 !provider_id_is_legacy(&receipt.spawn.provider)
15550 }
15551 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15552 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15553 !provider_id_is_legacy(&route.provider)
15554 }
15555 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15556 !provider_id_is_legacy(&selection.route.provider)
15557 }
15558 _ => false,
15559 };
15560 if contains_open_record {
15561 reply.result = Err(failure(
15562 NodeFailureCode::UnsupportedCapability,
15563 "open provider IDs were not negotiated",
15564 ));
15565 return;
15566 }
15567 let Ok(response) = reply.result.as_mut() else {
15568 return;
15569 };
15570 match response {
15571 NodeResponse::Snapshot { snapshot, .. } => {
15572 project_snapshot_legacy_provider_ids(snapshot);
15573 }
15574 NodeResponse::Resync { snapshot, events, .. } => {
15575 project_snapshot_legacy_provider_ids(snapshot);
15576 *events = std::mem::take(events)
15577 .into_iter()
15578 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15579 .collect();
15580 }
15581 NodeResponse::WorkspaceRegistered { workspace }
15582 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15583 | NodeResponse::WorktreeCreated { workspace, .. } => {
15584 workspace
15585 .sessions
15586 .retain(|session| provider_id_is_legacy(&session.agent_id));
15587 }
15588 NodeResponse::Armed { .. }
15589 | NodeResponse::Spawned { .. }
15590 | NodeResponse::Activated { .. }
15591 | NodeResponse::Aborted { .. }
15592 | NodeResponse::ReplyChunkAccepted { .. }
15593 | NodeResponse::CallRejected { .. }
15594 | NodeResponse::DeliveryStageBegun { .. }
15595 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15596 | NodeResponse::DeliveryCommitted { .. }
15597 | NodeResponse::DeliveryStageAborted { .. }
15598 | NodeResponse::WorkspaceInspected { .. }
15599 | NodeResponse::HostDirectoriesBrowsed { .. }
15600 | NodeResponse::WorkspaceFileRead { .. }
15601 | NodeResponse::WorkspaceFileWritten { .. }
15602 | NodeResponse::WorkspaceFileCreated { .. }
15603 | NodeResponse::WorkspaceDirectoryCreated { .. }
15604 | NodeResponse::GitHistoryRead { .. }
15605 | NodeResponse::GitDiffRead { .. }
15606 | NodeResponse::Controller { .. }
15607 | NodeResponse::SpawnAccepted { .. }
15608 | NodeResponse::SpawnSpecAccepted { .. }
15609 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15610 | NodeResponse::ManagedWorktreeCleanup { .. }
15611 | NodeResponse::NativeSessionsCataloged { .. }
15612 | NodeResponse::NativeSessionsPaged { .. }
15613 | NodeResponse::NativeSessionPreviewed { .. }
15614 | NodeResponse::SessionRecordPreviewed { .. }
15615 | NodeResponse::HistoryDiscovered { .. }
15616 | NodeResponse::HistoryLoaded { .. }
15617 | NodeResponse::ContextPackForSessionRecordExported { .. }
15618 | NodeResponse::ContextPackExported { .. }
15619 | NodeResponse::ContextPackForgotten { .. }
15620 | NodeResponse::DurableContextPackResolved { .. }
15621 | NodeResponse::ContextPackBytesRead { .. }
15622 | NodeResponse::SessionRecordUpdated { .. }
15623 | NodeResponse::ProviderSessionIndexed { .. }
15624 | NodeResponse::NativeSessionIndexed { .. }
15625 | NodeResponse::SessionRecordResumed { .. }
15626 | NodeResponse::SessionRecordForgotten { .. }
15627 | NodeResponse::WorkspaceUnregistered { .. }
15628 | NodeResponse::WorktreeRemoved { .. }
15629 | NodeResponse::Accepted
15630 | NodeResponse::ShuttingDown => {}
15631 }
15632}
15633
15634fn project_event_legacy_provider_ids(
15635 shared: &NodeShared,
15636 mut envelope: NodeEventEnvelope,
15637) -> Option<NodeEventEnvelope> {
15638 let include = match &mut envelope.event {
15639 NodeEvent::HarnessMcpReadCall { .. } => true,
15640 NodeEvent::Control { address, .. }
15641 | NodeEvent::AgentStream { address, .. }
15642 | NodeEvent::TerminalFrame { address, .. } => shared
15643 .validate_address(address)
15644 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15645 NodeEvent::WorkspaceAdded { workspace } => {
15646 workspace
15647 .sessions
15648 .retain(|session| provider_id_is_legacy(&session.agent_id));
15649 true
15650 }
15651 NodeEvent::SessionRecordUpserted { record } => {
15652 provider_id_is_legacy(&record.provider)
15653 }
15654 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15655 NodeEvent::SessionRecordRemoved { .. } => shared
15656 .history
15657 .lock()
15658 .unwrap_or_else(|poisoned| poisoned.into_inner())
15659 .removed_record_providers
15660 .get(&envelope.sequence)
15661 .is_some_and(provider_id_is_legacy),
15662 NodeEvent::ControllerChanged { .. }
15663 | NodeEvent::ManagedWorktreeUpserted { .. }
15664 | NodeEvent::ManagedWorktreeRemoved { .. }
15665 | NodeEvent::WorkspaceRemoved { .. }
15666 | NodeEvent::ResyncRequired { .. } => true,
15667 };
15668 include.then_some(envelope)
15669}
15670
15671fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15672 let Ok(response) = reply.result.as_mut() else {
15673 return;
15674 };
15675 match response {
15676 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15677 snapshot.provider_runtime_statuses.clear();
15678 }
15679 NodeResponse::Armed { .. }
15680 | NodeResponse::Spawned { .. }
15681 | NodeResponse::Activated { .. }
15682 | NodeResponse::Aborted { .. }
15683 | NodeResponse::ReplyChunkAccepted { .. }
15684 | NodeResponse::CallRejected { .. }
15685 | NodeResponse::DeliveryStageBegun { .. }
15686 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15687 | NodeResponse::DeliveryCommitted { .. }
15688 | NodeResponse::DeliveryStageAborted { .. }
15689 | NodeResponse::WorkspaceInspected { .. }
15690 | NodeResponse::HostDirectoriesBrowsed { .. }
15691 | NodeResponse::WorkspaceFileRead { .. }
15692 | NodeResponse::WorkspaceFileWritten { .. }
15693 | NodeResponse::WorkspaceFileCreated { .. }
15694 | NodeResponse::WorkspaceDirectoryCreated { .. }
15695 | NodeResponse::GitHistoryRead { .. }
15696 | NodeResponse::GitDiffRead { .. }
15697 | NodeResponse::Controller { .. }
15698 | NodeResponse::SpawnAccepted { .. }
15699 | NodeResponse::SpawnSpecAccepted { .. }
15700 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15701 | NodeResponse::ManagedWorktreeCleanup { .. }
15702 | NodeResponse::NativeSessionsCataloged { .. }
15703 | NodeResponse::NativeSessionsPaged { .. }
15704 | NodeResponse::NativeSessionPreviewed { .. }
15705 | NodeResponse::SessionRecordPreviewed { .. }
15706 | NodeResponse::HistoryDiscovered { .. }
15707 | NodeResponse::HistoryLoaded { .. }
15708 | NodeResponse::ContextPackForSessionRecordExported { .. }
15709 | NodeResponse::ContextPackExported { .. }
15710 | NodeResponse::ContextPackForgotten { .. }
15711 | NodeResponse::DurableContextPackResolved { .. }
15712 | NodeResponse::ContextPackBytesRead { .. }
15713 | NodeResponse::SessionRecordUpdated { .. }
15714 | NodeResponse::ProviderSessionIndexed { .. }
15715 | NodeResponse::NativeSessionIndexed { .. }
15716 | NodeResponse::SessionRecordResumed { .. }
15717 | NodeResponse::SessionRecordForgotten { .. }
15718 | NodeResponse::WorkspaceRegistered { .. }
15719 | NodeResponse::StandaloneWorkspaceCreated { .. }
15720 | NodeResponse::WorkspaceUnregistered { .. }
15721 | NodeResponse::WorktreeCreated { .. }
15722 | NodeResponse::WorktreeRemoved { .. }
15723 | NodeResponse::Accepted
15724 | NodeResponse::ShuttingDown => {}
15725 }
15726}
15727
15728struct ControllerReleaseGuard {
15729 shared: Arc<NodeShared>,
15730 connection_id: u64,
15731}
15732
15733impl Drop for ControllerReleaseGuard {
15734 fn drop(&mut self) {
15735 self.shared.release_controller(self.connection_id);
15736 }
15737}
15738
15739struct AbortTaskOnDrop(AbortHandle);
15740
15741impl Drop for AbortTaskOnDrop {
15742 fn drop(&mut self) {
15743 self.0.abort();
15744 }
15745}
15746
15747fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15752 std::env::var(name)
15753 .ok()
15754 .and_then(|value| value.parse::<u64>().ok())
15755 .filter(|value| *value > 0)
15756 .unwrap_or(default)
15757 .min(max)
15758}
15759
15760fn workspace_inspection_time_budget_ms() -> u64 {
15761 env_config_bounded(
15762 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15763 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15764 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15765 )
15766}
15767
15768fn workspace_inspection_entry_cap() -> usize {
15769 env_config_bounded(
15770 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15771 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15772 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15773 ) as usize
15774}
15775
15776struct WorkspaceWalkBudget {
15782 deadline: Instant,
15783 entry_cap: usize,
15784 entries_visited: u64,
15785 time_budget_exceeded: bool,
15786 entry_cap_exceeded: bool,
15787}
15788
15789impl WorkspaceWalkBudget {
15790 fn new(deadline: Instant, entry_cap: usize) -> Self {
15791 Self {
15792 deadline,
15793 entry_cap,
15794 entries_visited: 0,
15795 time_budget_exceeded: false,
15796 entry_cap_exceeded: false,
15797 }
15798 }
15799
15800 fn is_exhausted(&self) -> bool {
15801 self.time_budget_exceeded || self.entry_cap_exceeded
15802 }
15803
15804 fn record_visit(&mut self) -> bool {
15807 self.entries_visited += 1;
15808 if self.entries_visited as usize >= self.entry_cap {
15809 self.entry_cap_exceeded = true;
15810 }
15811 if Instant::now() >= self.deadline {
15812 self.time_budget_exceeded = true;
15813 }
15814 self.is_exhausted()
15815 }
15816}
15817
15818fn collect_workspace_entries(
15819 root: &Path,
15820 deadline: Instant,
15821 entry_cap: usize,
15822) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15823 let mut entries = Vec::new();
15824 let mut truncated = false;
15825 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15826 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15827 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15831 (entries, truncated, budget)
15832}
15833
15834fn workspace_inspection_truncation(
15838 walk_budget: &WorkspaceWalkBudget,
15839 git_time_budget_exceeded: bool,
15840 elapsed: Duration,
15841) -> Option<WorkspaceInspectionTruncationV1> {
15842 if !walk_budget.time_budget_exceeded
15843 && !walk_budget.entry_cap_exceeded
15844 && !git_time_budget_exceeded
15845 {
15846 return None;
15847 }
15848 Some(WorkspaceInspectionTruncationV1 {
15849 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15850 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15851 git_time_budget_exceeded,
15852 entries_visited: walk_budget.entries_visited,
15853 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15854 })
15855}
15856
15857fn walk_workspace_directory(
15858 directory: &Path,
15859 relative_directory: Option<&RepositoryPath>,
15860 depth: usize,
15861 entries: &mut Vec<WorkspaceEntry>,
15862 truncated: &mut bool,
15863 budget: &mut WorkspaceWalkBudget,
15864) {
15865 if budget.is_exhausted() {
15866 *truncated = true;
15867 return;
15868 }
15869 let read_dir = match std::fs::read_dir(directory) {
15870 Ok(read_dir) => read_dir,
15871 Err(_) => {
15872 *truncated = true;
15873 return;
15874 }
15875 };
15876 let mut children = Vec::new();
15877 for child in read_dir {
15878 match child {
15879 Ok(child) => children.push(child),
15880 Err(_) => *truncated = true,
15881 }
15882 }
15883 children.sort_by_key(|child| child.file_name());
15884 for child in children {
15885 if budget.record_visit() {
15886 *truncated = true;
15887 return;
15888 }
15889 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15890 *truncated = true;
15891 return;
15892 }
15893 let file_type = match child.file_type() {
15894 Ok(file_type) => file_type,
15895 Err(_) => {
15896 *truncated = true;
15897 continue;
15898 }
15899 };
15900 if file_type.is_symlink() {
15901 continue;
15902 }
15903 let name = match child.file_name().into_string() {
15904 Ok(name) => name,
15905 Err(_) => {
15906 *truncated = true;
15907 continue;
15908 }
15909 };
15910 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15911 continue;
15912 }
15913 let relative_path = match relative_directory {
15914 None => windows_repository_path(name),
15915 Some(parent) => parent
15916 .as_utf8()
15917 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15918 };
15919 let Some(relative_path) = relative_path else {
15920 *truncated = true;
15921 continue;
15922 };
15923 let kind = if file_type.is_dir() {
15924 WorkspaceEntryKind::Directory
15925 } else if file_type.is_file() {
15926 WorkspaceEntryKind::File
15927 } else {
15928 continue;
15929 };
15930 entries.push(WorkspaceEntry {
15931 relative_path: relative_path.clone(),
15932 kind,
15933 });
15934 if file_type.is_dir() {
15935 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15936 walk_workspace_directory(
15937 &child.path(),
15938 Some(&relative_path),
15939 depth + 1,
15940 entries,
15941 truncated,
15942 budget,
15943 );
15944 } else {
15945 *truncated = true;
15946 }
15947 }
15948 }
15949}
15950
15951fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15952 if value.contains('\\') {
15953 return None;
15954 }
15955 RepositoryPath::utf8(value).ok()
15956}
15957
15958fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15959 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15960}
15961
15962fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15971 if name.eq_ignore_ascii_case(".git")
15972 || name.eq_ignore_ascii_case("target")
15973 || name.eq_ignore_ascii_case("node_modules")
15974 {
15975 return true;
15976 }
15977 path.join("CACHEDIR.TAG").is_file()
15978}
15979
15980fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15981 matches!(
15982 status,
15983 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15984 )
15985}
15986
15987#[derive(Clone, Debug, Eq, PartialEq)]
15988enum ContextPackRepositoryHead {
15989 NotRepository,
15990 Unborn,
15991 Commit(String),
15992}
15993
15994#[derive(Clone, Debug, Eq, PartialEq)]
15995struct ContextPackRepositoryObservation {
15996 head: ContextPackRepositoryHead,
15997 git: GitSnapshot,
15998 files: Vec<ContextPackRepositoryFileSource>,
15999}
16000
16001fn stable_context_pack_repository(
16002 first: ContextPackRepositoryObservation,
16003 second: ContextPackRepositoryObservation,
16004) -> Result<ContextPackRepository, NodeFailure> {
16005 if first != second {
16006 return Err(failure(
16007 NodeFailureCode::ContextPackMaterializationFailed,
16008 "context repository changed during bounded capture",
16009 ));
16010 }
16011 Ok(ContextPackRepository::from_sources(
16012 &second.git,
16013 second.files,
16014 ))
16015}
16016
16017fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16024 if Instant::now() < deadline {
16025 return false;
16026 }
16027 append_git_diagnostic(
16028 snapshot,
16029 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16030 );
16031 true
16032}
16033
16034async fn run_git_bounded_with_deadline(
16043 root: &str,
16044 arguments: &[&str],
16045 output_limit: usize,
16046 deadline: Instant,
16047) -> io::Result<GitCommandOutput> {
16048 let remaining = deadline.checked_duration_since(Instant::now());
16049 let timeout_ms = remaining
16050 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16051 .unwrap_or(0)
16052 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16053 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16054}
16055
16056async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16063 let mut snapshot = GitSnapshot {
16064 is_repository: false,
16065 branch: None,
16066 status: Vec::new(),
16067 recent_commits: Vec::new(),
16068 worktrees: Vec::new(),
16069 managed_worktree: None,
16070 truncated: false,
16071 diagnostic: None,
16072 };
16073 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16074 return (snapshot, true);
16075 }
16076 let repository = match run_git_bounded_with_deadline(
16077 root,
16078 &["rev-parse", "--is-inside-work-tree"],
16079 4 * 1_024,
16080 deadline,
16081 )
16082 .await
16083 {
16084 Ok(output) => output,
16085 Err(error) => {
16086 append_git_diagnostic(
16087 &mut snapshot,
16088 &format!("git inspection unavailable: {error}"),
16089 );
16090 return (snapshot, false);
16091 }
16092 };
16093 snapshot.truncated |= repository.truncated;
16094 if repository.timed_out {
16095 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16096 return (snapshot, false);
16097 }
16098 if !repository.success
16099 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16100 {
16101 let stderr = String::from_utf8_lossy(&repository.stderr);
16102 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16103 append_git_diagnostic(&mut snapshot, stderr.trim());
16104 }
16105 return (snapshot, false);
16106 }
16107 snapshot.is_repository = true;
16108
16109 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16110 return (snapshot, true);
16111 }
16112 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16113 Ok(output) => {
16114 snapshot.truncated |= output.truncated;
16115 if output.timed_out {
16116 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16117 } else if output.success {
16118 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16119 if !branch.is_empty() {
16120 snapshot.branch = Some(truncate_git_field(
16121 &branch,
16122 MAX_REPOSITORY_PATH_BYTES,
16123 &mut snapshot.truncated,
16124 ));
16125 }
16126 }
16127 }
16128 Err(error) => append_git_diagnostic(
16129 &mut snapshot,
16130 &format!("git branch query failed: {error}"),
16131 ),
16132 }
16133 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16134 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16135 snapshot.truncated |= output.truncated;
16136 if output.success && !output.timed_out {
16137 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16138 if !head.is_empty() {
16139 snapshot.branch = Some(format!("detached:{head}"));
16140 }
16141 }
16142 }
16143 }
16144
16145 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16146 return (snapshot, true);
16147 }
16148 match run_git_bounded_with_deadline(
16149 root,
16150 &[
16151 "status",
16152 "--porcelain=v1",
16153 "-z",
16154 "--renames",
16155 "--untracked-files=normal",
16156 "--",
16157 ".",
16158 ],
16159 GIT_OUTPUT_MAX_BYTES,
16160 deadline,
16161 )
16162 .await
16163 {
16164 Ok(output) => {
16165 snapshot.truncated |= output.truncated;
16166 if output.timed_out {
16167 append_git_diagnostic(&mut snapshot, "git status timed out");
16168 } else if output.success {
16169 parse_git_status(&output.stdout, &mut snapshot);
16170 } else {
16171 append_git_diagnostic(
16172 &mut snapshot,
16173 String::from_utf8_lossy(&output.stderr).trim(),
16174 );
16175 }
16176 }
16177 Err(error) => append_git_diagnostic(
16178 &mut snapshot,
16179 &format!("git status failed: {error}"),
16180 ),
16181 }
16182
16183 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16184 return (snapshot, true);
16185 }
16186 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16187 match run_git_bounded_with_deadline(
16188 root,
16189 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16190 16 * 1_024,
16191 deadline,
16192 )
16193 .await
16194 {
16195 Ok(output) => {
16196 snapshot.truncated |= output.truncated;
16197 if output.timed_out {
16198 append_git_diagnostic(&mut snapshot, "git log timed out");
16199 } else if output.success {
16200 parse_git_commits(&output.stdout, &mut snapshot);
16201 } else {
16202 let stderr = String::from_utf8_lossy(&output.stderr);
16203 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16204 append_git_diagnostic(&mut snapshot, stderr.trim());
16205 }
16206 }
16207 }
16208 Err(error) => append_git_diagnostic(
16209 &mut snapshot,
16210 &format!("git log failed: {error}"),
16211 ),
16212 }
16213
16214 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16215 return (snapshot, true);
16216 }
16217 match list_git_worktrees_with_deadline(root, deadline).await {
16218 Ok(worktrees) => {
16219 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16220 }
16221 Err(error) => append_git_diagnostic(
16222 &mut snapshot,
16223 &format!("git worktree list failed: {}", error.message),
16224 ),
16225 }
16226 (snapshot, false)
16227}
16228
16229fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16230 let mut remaining = output;
16231 while !remaining.is_empty() {
16232 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16233 snapshot.truncated = true;
16234 break;
16235 }
16236 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16237 snapshot.truncated = true;
16238 break;
16239 };
16240 let record = &remaining[..record_end];
16241 remaining = &remaining[record_end + 1..];
16242 let has_previous_path = record
16243 .get(..2)
16244 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16245 let previous_path_bytes = if has_previous_path {
16246 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16247 snapshot.truncated = true;
16248 break;
16249 };
16250 let previous = &remaining[..previous_end];
16251 remaining = &remaining[previous_end + 1..];
16252 Some(previous)
16253 } else {
16254 None
16255 };
16256 if record.len() < 4
16257 || record[2] != b' '
16258 || !is_git_status_code(record[0])
16259 || !is_git_status_code(record[1])
16260 {
16261 snapshot.truncated = true;
16262 continue;
16263 }
16264 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16265 snapshot.truncated = true;
16266 continue;
16267 };
16268 let previous_path = match previous_path_bytes {
16269 Some(previous) => {
16270 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16271 snapshot.truncated = true;
16272 continue;
16273 };
16274 Some(previous)
16275 }
16276 None => None,
16277 };
16278 snapshot.status.push(GitStatusEntry {
16279 index_status: (record[0] as char).to_string(),
16280 worktree_status: (record[1] as char).to_string(),
16281 path,
16282 previous_path,
16283 });
16284 }
16285}
16286
16287fn is_git_status_code(byte: u8) -> bool {
16288 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16289}
16290
16291fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16292 for line in String::from_utf8_lossy(output).lines() {
16293 let Some((id, summary)) = line.split_once('\u{1f}') else {
16294 continue;
16295 };
16296 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16297 snapshot.truncated = true;
16298 break;
16299 }
16300 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16301 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16302 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16303 }
16304}
16305
16306fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16307 if value.len() <= max_bytes {
16308 return value.to_owned();
16309 }
16310 *truncated = true;
16311 let mut boundary = max_bytes;
16312 while boundary > 0 && !value.is_char_boundary(boundary) {
16313 boundary -= 1;
16314 }
16315 value[..boundary].to_owned()
16316}
16317
16318fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16319 if message.is_empty() {
16320 return;
16321 }
16322 let normalized = message
16323 .chars()
16324 .map(|character| if character.is_control() { ' ' } else { character })
16325 .collect::<String>();
16326 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16327 if !diagnostic.is_empty() {
16328 diagnostic.push_str("; ");
16329 }
16330 diagnostic.push_str(normalized.trim());
16331 snapshot.diagnostic = Some(truncate_git_field(
16332 &diagnostic,
16333 GIT_DIAGNOSTIC_MAX_BYTES,
16334 &mut snapshot.truncated,
16335 ));
16336}
16337
16338async fn run_git_bounded(
16339 root: &str,
16340 arguments: &[&str],
16341 output_limit: usize,
16342) -> io::Result<GitCommandOutput> {
16343 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16344}
16345
16346async fn read_git_history_bounded(
16347 root: &str,
16348 path: Option<&RepositoryPath>,
16349 before: Option<&GitObjectId>,
16350 limit: u16,
16351) -> Result<GitHistoryPage, NodeFailure> {
16352 let path = path
16353 .map(|path| {
16354 path.as_utf8().ok_or_else(|| {
16355 failure(
16356 NodeFailureCode::InvalidRepositoryPath,
16357 "git history path must use UTF-8",
16358 )
16359 })
16360 })
16361 .transpose()?
16362 .unwrap_or(".");
16363 let count = (usize::from(limit) + 1).to_string();
16364 let start = before
16365 .map(|revision| format!("{}^", revision.as_str()))
16366 .unwrap_or_else(|| "HEAD".to_owned());
16367 let arguments = [
16368 "--no-pager",
16369 "log",
16370 "--no-decorate",
16371 "--date=iso-strict",
16372 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16373 "-n",
16374 count.as_str(),
16375 start.as_str(),
16376 "--",
16377 path,
16378 ];
16379 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16380 .await
16381 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16382 if output.timed_out {
16383 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16384 }
16385 if !output.success {
16386 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16387 }
16388 let mut commits = parse_git_history(&output.stdout);
16389 let has_more = commits.len() > usize::from(limit) || output.truncated;
16390 commits.truncate(usize::from(limit));
16391 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16392 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16393}
16394
16395fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16396 String::from_utf8_lossy(output)
16397 .split('\u{1e}')
16398 .filter_map(|record| {
16399 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16400 if fields.len() != 9 {
16401 return None;
16402 }
16403 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16404 let parents = if fields[1].is_empty() {
16405 Vec::new()
16406 } else {
16407 fields[1]
16408 .split(' ')
16409 .map(|value| GitObjectId::new(value.to_owned()))
16410 .collect::<Result<Vec<_>, _>>()
16411 .ok()?
16412 };
16413 Some(GitCommitDetails {
16414 id,
16415 parents,
16416 subject: bounded_git_text(fields[2], 512),
16417 author_name: bounded_git_text(fields[3], 256),
16418 author_email: bounded_git_text(fields[4], 320),
16419 authored_at: bounded_git_text(fields[5], 64),
16420 committer_name: bounded_git_text(fields[6], 256),
16421 committer_email: bounded_git_text(fields[7], 320),
16422 committed_at: bounded_git_text(fields[8], 64),
16423 signature_status: GitSignatureStatus::NoSignature,
16424 signer: None,
16425 })
16426 })
16427 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16428 .collect()
16429}
16430
16431fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16432 let normalized = value
16433 .chars()
16434 .map(|character| if character.is_control() { ' ' } else { character })
16435 .collect::<String>();
16436 if normalized.len() <= max_bytes {
16437 return normalized;
16438 }
16439 let mut boundary = max_bytes;
16440 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16441 boundary -= 1;
16442 }
16443 normalized[..boundary].to_owned()
16444}
16445
16446async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16447 let path = request.path.as_ref().map(|path| {
16448 path.as_utf8()
16449 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16450 }).transpose()?;
16451 let mut arguments = match &request.mode {
16452 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16453 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16454 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16455 };
16456 if let GitDiffMode::Commit { revision } = &request.mode {
16457 arguments.push(revision.as_str());
16458 }
16459 arguments.push("--");
16460 if let Some(path) = path {
16461 arguments.push(path);
16462 }
16463 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16464 .await
16465 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16466 if output.timed_out {
16467 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16468 }
16469 if !output.success {
16470 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16471 }
16472 Ok(GitDiff {
16473 mode: request.mode,
16474 path: request.path,
16475 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16476 truncated: output.truncated,
16477 })
16478}
16479
16480async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16481 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16482 ResponseEnvelope { request_id: envelope.request_id, result }
16483}
16484
16485async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16486 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16487 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16488 }
16489 let read_only = matches!(
16490 &request,
16491 NodeRequest::Snapshot
16492 | NodeRequest::Resync { .. }
16493 | NodeRequest::BrowseHostDirectories { .. }
16494 | NodeRequest::InspectWorkspace { .. }
16495 | NodeRequest::ReadWorkspaceFile { .. }
16496 | NodeRequest::ReadGitHistory { .. }
16497 | NodeRequest::ReadGitDiff { .. }
16498 | NodeRequest::CatalogNativeSessions { .. }
16499 | NodeRequest::PageNativeSessions { .. }
16500 | NodeRequest::PreviewNativeSession { .. }
16501 | NodeRequest::PreviewSessionRecord { .. }
16502 );
16503 let _mutation_guard = if read_only {
16504 None
16505 } else {
16506 let guard = shared.mutation_gate.lock().await;
16507 if shared.shutdown.load(Ordering::Acquire) {
16508 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16509 }
16510 Some(guard)
16511 };
16512 match request {
16513 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16514 event_sequence: shared.current_sequence(),
16515 controller: shared.controller_state(),
16516 snapshot: shared.snapshot(),
16517 }),
16518 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16519 NodeRequest::ArmHarnessMcpReservation {
16520 reservation_id,
16521 activation_digest,
16522 spawn_spec,
16523 launch,
16524 expires_at_unix_ms,
16525 } => {
16526 shared.require_controller(connection_id, role)?;
16527 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16528 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16529 })?;
16530 let expires_at_unix_ms = registry
16531 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16532 .await
16533 .map_err(harness_mcp_failure)?;
16534 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16535 }
16536 NodeRequest::SpawnSpecWithHarnessMcp {
16537 reservation_id,
16538 activation_digest,
16539 spec,
16540 deadline_unix_ms,
16541 } => {
16542 shared.require_controller(connection_id, role)?;
16543 let receipt = shared
16544 .spawn_from_spec_with_harness_mcp(
16545 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16546 )
16547 .await?;
16548 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16549 }
16550 NodeRequest::ActivateHarnessMcpReservation {
16551 reservation_id,
16552 activation_digest,
16553 record_id,
16554 session,
16555 } => {
16556 shared.require_controller(connection_id, role)?;
16557 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16558 current.instance_id == session.session.instance_id
16559 && current.generation == session.session.generation
16560 }).and_then(|current| current.process_id).ok_or_else(|| {
16561 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16562 })?;
16563 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16564 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16565 })?.activate(
16566 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16567 ).map_err(harness_mcp_failure)?;
16568 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16569 }
16570 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16571 shared.require_controller(connection_id, role)?;
16572 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16573 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16574 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16575 if let Some(instance_id) = instance_id {
16576 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16577 }
16578 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16579 }
16580 NodeRequest::PutHarnessMcpReplyChunk {
16581 reservation_id, activation_digest, record_id, session, call_id,
16582 offset, final_chunk, chunk_hex,
16583 } => {
16584 shared.require_controller(connection_id, role)?;
16585 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16586 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16587 })?.put_reply_chunk(
16588 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16589 offset, final_chunk, &chunk_hex,
16590 ).map_err(harness_mcp_failure)?;
16591 Ok(NodeResponse::ReplyChunkAccepted {
16592 reservation_id, activation_digest, record_id, session, call_id,
16593 next_offset, completed,
16594 })
16595 }
16596 NodeRequest::RejectHarnessMcpCall {
16597 reservation_id, activation_digest, record_id, session, call_id, reason,
16598 } => {
16599 shared.require_controller(connection_id, role)?;
16600 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16601 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16602 })?.reject_call(
16603 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16604 ).map_err(harness_mcp_failure)?;
16605 Ok(NodeResponse::CallRejected {
16606 reservation_id, activation_digest, record_id, session, call_id,
16607 })
16608 }
16609 NodeRequest::BeginDeliveryStage { manifest } => {
16610 shared.require_controller(connection_id, role)?;
16611 shared.begin_delivery_stage(manifest)
16612 }
16613 NodeRequest::PutDeliveryBlobChunk {
16614 stage_id,
16615 blob_digest,
16616 offset,
16617 chunk_hex,
16618 } => {
16619 shared.require_controller(connection_id, role)?;
16620 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16621 }
16622 NodeRequest::CommitDeliveryStage { stage_id } => {
16623 shared.require_controller(connection_id, role)?;
16624 let receipt = shared.commit_delivery_stage(stage_id)?;
16625 Ok(NodeResponse::DeliveryCommitted { receipt })
16626 }
16627 NodeRequest::AbortDeliveryStage { stage_id } => {
16628 shared.require_controller(connection_id, role)?;
16629 shared.abort_delivery_stage(&stage_id)?;
16630 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16631 }
16632 NodeRequest::BrowseHostDirectories { directory, after } => {
16633 let listing = shared.browse_host_directories(directory, after).await?;
16634 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16635 }
16636 NodeRequest::InspectWorkspace { workspace_id } => {
16637 let inspection = shared.inspect_workspace(workspace_id).await?;
16638 Ok(NodeResponse::WorkspaceInspected { inspection })
16639 }
16640 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16641 let file = shared.read_workspace_file(workspace_id, path).await?;
16642 Ok(NodeResponse::WorkspaceFileRead { file })
16643 }
16644 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16645 shared.require_controller(connection_id, role)?;
16646 let file = shared
16647 .write_workspace_file(workspace_id, path, expected_revision, text)
16648 .await?;
16649 Ok(NodeResponse::WorkspaceFileWritten { file })
16650 }
16651 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16652 shared.require_controller(connection_id, role)?;
16653 let file = shared.create_workspace_file(workspace_id, path).await?;
16654 Ok(NodeResponse::WorkspaceFileCreated { file })
16655 }
16656 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16657 shared.require_controller(connection_id, role)?;
16658 let entry = shared
16659 .create_workspace_directory(workspace_id.clone(), path)
16660 .await?;
16661 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16662 }
16663 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16664 if role != ClientRole::Operator {
16665 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16666 }
16667 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16668 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16669 }
16670 let page = shared
16671 .read_git_history(workspace_id.clone(), path, before, limit)
16672 .await?;
16673 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16674 }
16675 NodeRequest::ReadGitDiff { workspace_id, request } => {
16676 if role != ClientRole::Operator {
16677 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16678 }
16679 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16680 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16681 }
16682 NodeRequest::CatalogNativeSessions { route, limit } => {
16683 if role != ClientRole::Operator {
16684 return Err(failure(
16685 NodeFailureCode::ObserverReadOnly,
16686 "native session catalog requires an operator connection",
16687 ));
16688 }
16689 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16690 return Err(failure(
16691 NodeFailureCode::InvalidRequest,
16692 "native session catalog limit is invalid",
16693 ));
16694 }
16695 let (entries, summary) = shared
16696 .catalog_native_sessions(route.clone(), limit)
16697 .await?;
16698 Ok(NodeResponse::NativeSessionsCataloged {
16699 route,
16700 entries,
16701 summary: Some(summary),
16702 })
16703 }
16704 NodeRequest::PageNativeSessions {
16705 route,
16706 window,
16707 catalog_revision,
16708 recent_cutoff_unix_ms,
16709 after_selection_id,
16710 limit,
16711 } => {
16712 if role != ClientRole::Operator {
16713 return Err(failure(
16714 NodeFailureCode::ObserverReadOnly,
16715 "native session catalog paging requires an operator connection",
16716 ));
16717 }
16718 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16719 || after_selection_id
16720 .as_deref()
16721 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16722 {
16723 return Err(failure(
16724 NodeFailureCode::InvalidRequest,
16725 "native session catalog page request is invalid",
16726 ));
16727 }
16728 let page = shared
16729 .page_native_sessions(
16730 route.clone(),
16731 window,
16732 catalog_revision,
16733 recent_cutoff_unix_ms,
16734 after_selection_id,
16735 limit,
16736 )
16737 .await?;
16738 Ok(NodeResponse::NativeSessionsPaged {
16739 route,
16740 page,
16741 })
16742 }
16743 NodeRequest::PreviewNativeSession {
16744 selection,
16745 message_limit,
16746 } => {
16747 if role != ClientRole::Operator {
16748 return Err(failure(
16749 NodeFailureCode::ObserverReadOnly,
16750 "native session preview requires an operator connection",
16751 ));
16752 }
16753 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16754 .contains(&message_limit)
16755 || selection.validate().is_err()
16756 {
16757 return Err(failure(
16758 NodeFailureCode::InvalidRequest,
16759 "native session preview request is invalid",
16760 ));
16761 }
16762 let preview = shared
16763 .preview_native_session(selection.clone(), message_limit)
16764 .await?;
16765 Ok(NodeResponse::NativeSessionPreviewed {
16766 selection,
16767 preview,
16768 })
16769 }
16770 NodeRequest::IndexNativeSession {
16771 selection,
16772 display_name,
16773 } => {
16774 shared.require_controller(connection_id, role)?;
16775 let record = shared
16776 .index_native_session(selection.clone(), display_name)
16777 .await?;
16778 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16779 }
16780 NodeRequest::PreviewSessionRecord {
16781 record_id,
16782 message_limit,
16783 } => {
16784 if role != ClientRole::Operator {
16785 return Err(failure(
16786 NodeFailureCode::ObserverReadOnly,
16787 "native session preview requires an operator connection",
16788 ));
16789 }
16790 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16791 .contains(&message_limit)
16792 {
16793 return Err(failure(
16794 NodeFailureCode::InvalidRequest,
16795 "native session preview request is invalid",
16796 ));
16797 }
16798 let record = shared.record(&record_id)?;
16799 let working_directory = shared.workspace_root(&record.workspace_id)?;
16800 if !platform::roots_equal(
16801 &working_directory,
16802 windows_path_text(&record.canonical_root),
16803 ) {
16804 return Err(failure(
16805 NodeFailureCode::SessionRecordConflict,
16806 "managed session workspace root changed; refusing to preview another directory",
16807 ));
16808 }
16809 let identity = record.provider_session.clone().ok_or_else(|| {
16810 failure(
16811 NodeFailureCode::BackendOperationFailed,
16812 "native session preview is unavailable for this session record",
16813 )
16814 })?;
16815 if identity.key != ProviderSessionKey::SessionId {
16816 return Err(failure(
16817 NodeFailureCode::BackendOperationFailed,
16818 "native session preview is unavailable for this session identity",
16819 ));
16820 }
16821 let preview = shared
16822 .preview_native_session_by_session_id(
16823 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16824 )
16825 .await?;
16826 if preview.session_id != identity.id {
16827 return Err(failure(
16828 NodeFailureCode::SessionRecordConflict,
16829 "native session preview identity changed while loading",
16830 ));
16831 }
16832 shared.revalidate_session_record_preview(&record, &identity)?;
16833 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16834 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16835 record_id: record_id.clone(),
16836 summary: SessionHistorySummaryV1::from(&preview),
16837 });
16838 Ok(NodeResponse::SessionRecordPreviewed {
16839 record_id,
16840 preview,
16841 })
16842 }
16843 NodeRequest::AcquireController { lease_ms } => {
16844 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16845 Ok(NodeResponse::Controller { controller: Some(controller) })
16846 }
16847 NodeRequest::ReleaseController => {
16848 shared.release_controller(connection_id);
16849 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16850 }
16851 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16852 shared.require_controller(connection_id, role)?;
16853 let root = require_windows_path(root)?;
16854 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16855 let workspace = shared.register_workspace(workspace_id, root).await?;
16856 Ok(NodeResponse::WorkspaceRegistered { workspace })
16857 }
16858 NodeRequest::CreateStandaloneWorkspace {
16859 workspace_id,
16860 root,
16861 initial_branch,
16862 } => {
16863 shared.require_controller(connection_id, role)?;
16864 let root = require_windows_path(root)?;
16865 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16866 let workspace = shared
16867 .create_standalone_workspace(workspace_id, root, initial_branch)
16868 .await?;
16869 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16870 }
16871 NodeRequest::UnregisterWorkspace { workspace_id } => {
16872 shared.require_controller(connection_id, role)?;
16873 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16874 shared.unregister_workspace(&workspace_id)?;
16875 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16876 }
16877 NodeRequest::CreateWorktree {
16878 source_workspace_id,
16879 workspace_id,
16880 target_root,
16881 branch,
16882 base,
16883 } => {
16884 shared.require_controller(connection_id, role)?;
16885 let target_root = require_windows_path(target_root)?;
16886 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16887 let (worktree, workspace) = shared
16888 .create_worktree(
16889 source_workspace_id,
16890 workspace_id,
16891 target_root,
16892 branch,
16893 base,
16894 )
16895 .await?;
16896 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16897 }
16898 NodeRequest::RemoveWorktree {
16899 source_workspace_id,
16900 target_root,
16901 } => {
16902 shared.require_controller(connection_id, role)?;
16903 let native_target_root = require_windows_path(target_root.clone())?;
16904 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16905 let workspace_id = shared
16906 .remove_worktree(source_workspace_id, native_target_root)
16907 .await?;
16908 Ok(NodeResponse::WorktreeRemoved {
16909 target_root,
16910 workspace_id,
16911 })
16912 }
16913 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16914 shared.require_controller(connection_id, role)?;
16915 if !terminal_size.is_valid() {
16916 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16917 }
16918 if let Some(prompt) = initial_prompt.as_deref() {
16919 validate_node_text("spawn initial prompt", prompt)?;
16920 }
16921 match shared
16922 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16923 .await
16924 {
16925 Ok(session) => {
16926 shared.mark_spawn_provisional(session.clone());
16927 Ok(NodeResponse::SpawnAccepted { session })
16928 }
16929 Err(err) => {
16930 tracing::warn!(
16931 %provider,
16932 %workspace_id,
16933 ?mode,
16934 code = ?err.code,
16935 message = %err.message,
16936 "session spawn refused"
16937 );
16938 Err(err)
16939 }
16940 }
16941 }
16942 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16943 shared.require_controller(connection_id, role)?;
16944 if !terminal_size.is_valid() {
16945 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16946 }
16947 if let Some(prompt) = initial_prompt.as_deref() {
16948 validate_node_text("spawn initial prompt", prompt)?;
16949 }
16950 let plan = backend_paths::plan_launch_options(
16951 provider.as_str(),
16952 mode,
16953 options.model.is_some(),
16954 options.effort.is_some(),
16955 )
16956 .map_err(|r| {
16957 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16958 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16959 })?;
16960 let selection = spawn_selection(&options)?;
16961 let pty_selection = match plan {
16962 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16963 _ => None,
16964 };
16965 let spawned = SPAWN_SESSION_OPTIONS
16966 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16967 .await;
16968 match spawned {
16969 Ok(session) => {
16970 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16971 shared
16972 .pending_acp_options
16973 .lock()
16974 .unwrap_or_else(|p| p.into_inner())
16975 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16976 }
16977 shared.mark_spawn_provisional(session.clone());
16978 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16979 Ok(NodeResponse::SpawnAccepted { session })
16980 }
16981 Err(err) => {
16982 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16983 Err(err)
16984 }
16985 }
16986 }
16987 NodeRequest::SpawnSpec { spec } => {
16988 shared.require_controller(connection_id, role)?;
16989 let receipt = shared.spawn_from_spec(spec).await?;
16990 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16991 }
16992 NodeRequest::SpawnManagedWorktree { request } => {
16993 shared.require_controller(connection_id, role)?;
16994 let receipt = shared.spawn_managed_worktree(request).await?;
16995 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16996 }
16997 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16998 shared.require_controller(connection_id, role)?;
16999 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17000 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17001 }
17002 NodeRequest::CleanupManagedWorktree { lease_id } => {
17003 shared.require_controller(connection_id, role)?;
17004 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17005 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17006 }
17007 NodeRequest::DiscoverHistory { session, limit } => {
17008 controlled_session(shared, connection_id, role, &session)?;
17009 let candidates = shared.discover_history(&session, limit).await?;
17010 Ok(NodeResponse::HistoryDiscovered {
17011 session,
17012 candidates,
17013 })
17014 }
17015 NodeRequest::LoadHistory {
17016 session,
17017 candidate_id,
17018 } => {
17019 controlled_session(shared, connection_id, role, &session)?;
17020 let loaded = shared
17021 .load_history(&session, candidate_id)
17022 .await?;
17023 Ok(NodeResponse::HistoryLoaded {
17024 session,
17025 session_id: loaded.session_id,
17026 message_count: loaded.message_count,
17027 completed_turn_count: loaded.completed_turn_count,
17028 })
17029 }
17030 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17031 shared.require_controller(connection_id, role)?;
17032 let context = shared
17033 .export_context_pack_for_session_record(&record_id, &session, false)
17034 .await?;
17035 Ok(NodeResponse::ContextPackForSessionRecordExported {
17036 record_id,
17037 session,
17038 context,
17039 })
17040 }
17041 NodeRequest::ExportContextPack { session } => {
17042 controlled_session(shared, connection_id, role, &session)?;
17043 let context = shared.export_context_pack(&session).await?;
17044 Ok(NodeResponse::ContextPackExported { context })
17045 }
17046 NodeRequest::ForgetContextPack { context_id } => {
17047 shared.require_controller(connection_id, role)?;
17048 shared.forget_context_pack(&context_id)?;
17049 Ok(NodeResponse::ContextPackForgotten { context_id })
17050 }
17051 NodeRequest::ResolveDurableContextPack { context_id } => {
17052 let context = shared
17056 .context_catalog
17057 .read()
17058 .unwrap_or_else(|poisoned| poisoned.into_inner())
17059 .get(&context_id)
17060 .map(|pack| pack.receipt().clone())
17061 .ok_or_else(|| {
17062 failure(
17063 NodeFailureCode::UnknownContextPack,
17064 "context pack is not present",
17065 )
17066 })?;
17067 Ok(NodeResponse::DurableContextPackResolved { context })
17068 }
17069 NodeRequest::ReadContextPack { digest } => {
17070 let (id, byte_len, bytes) = shared
17075 .context_catalog
17076 .read()
17077 .unwrap_or_else(|poisoned| poisoned.into_inner())
17078 .get_by_digest(&digest)
17079 .map(|pack| {
17080 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17081 })
17082 .ok_or_else(|| {
17083 failure(
17084 NodeFailureCode::UnknownContextPack,
17085 &format!("context pack {digest} not on this node"),
17086 )
17087 })?;
17088 tracing::debug!(
17089 digest = digest.as_str(),
17090 byte_len,
17091 "read context pack bytes",
17092 );
17093 Ok(NodeResponse::ContextPackBytesRead {
17094 pack: ContextPackBytesRead {
17095 digest,
17096 id,
17097 byte_len,
17098 bytes,
17099 },
17100 })
17101 }
17102 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17103 let provider = controlled_session(shared, connection_id, role, &session)?;
17104 if let Some(prompt) = initial_prompt.as_deref() {
17105 validate_node_text("resume initial prompt", prompt)?;
17106 }
17107 let runtime_requirement = if initial_prompt.is_some() {
17108 ProviderRuntimeRequirement::ResumeWithPrompt
17109 } else {
17110 ProviderRuntimeRequirement::Resume
17111 };
17112 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17113 let runtime_policy = shared
17114 .admit_provider_runtime(&provider, runtime_requirement)
17115 .await?;
17116 let working_directory = shared.bound_workspace_root(&session)?;
17117 let request = ResumeLaunchRequest {
17118 working_directory,
17119 terminal_size,
17120 initial_prompt,
17121 };
17122 request
17123 .validate()
17124 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17125 let command = shared.prepare_command(ControlCommand::Resume {
17126 instance_id: session.session.instance_id,
17127 target: ResumeTarget::CurrentProvider,
17128 runtime_policy,
17129 request,
17130 });
17131 shared.arm_resume(&session, command.id, runtime_policy)?;
17132 let dispatch = shared.dispatch_envelope(command);
17133 if let Err(error) = dispatch {
17134 shared.clear_armed_resume(&session);
17135 return Err(error);
17136 }
17137 Ok(NodeResponse::Accepted)
17138 }
17139 NodeRequest::RenameSessionRecord {
17140 record_id,
17141 display_name,
17142 } => {
17143 shared.require_controller(connection_id, role)?;
17144 let record = shared.rename_session_record(&record_id, display_name)?;
17145 Ok(NodeResponse::SessionRecordUpdated { record })
17146 }
17147 NodeRequest::SetSessionTask {
17148 record_id,
17149 expected_revision,
17150 target,
17151 } => {
17152 shared.require_controller(connection_id, role)?;
17153 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17154 Ok(NodeResponse::SessionRecordUpdated { record })
17155 }
17156 NodeRequest::IndexProviderSession {
17157 workspace_id,
17158 provider,
17159 identity,
17160 display_name,
17161 } => {
17162 shared.require_controller(connection_id, role)?;
17163 let record = shared.index_provider_session(
17164 workspace_id,
17165 provider,
17166 identity,
17167 display_name,
17168 )?;
17169 Ok(NodeResponse::ProviderSessionIndexed { record })
17170 }
17171 NodeRequest::ResumeSessionRecord {
17172 record_id,
17173 terminal_size,
17174 initial_prompt,
17175 } => {
17176 shared.require_controller(connection_id, role)?;
17177 if let Some(prompt) = initial_prompt.as_deref() {
17178 validate_node_text("managed resume initial prompt", prompt)?;
17179 }
17180 let (record, session) = shared
17181 .resume_session_record(
17182 &record_id,
17183 terminal_size,
17184 initial_prompt,
17185 )
17186 .await?;
17187 Ok(NodeResponse::SessionRecordResumed { record, session })
17188 }
17189 NodeRequest::ForgetSessionRecord { record_id } => {
17190 shared.require_controller(connection_id, role)?;
17191 shared.forget_session_record(&record_id).await?;
17192 Ok(NodeResponse::SessionRecordForgotten { record_id })
17193 }
17194 NodeRequest::Prompt { session, text } => {
17195 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17196 validate_node_text("prompt", &text)?;
17197 shared.require_session_runtime_policy(
17198 &session,
17199 ProviderRuntimeRequirement::SemanticPrompt,
17200 )?;
17201 let framing = prompt_framing(&agent_id);
17202 shared
17203 .dispatch_input_bounded(
17204 &session,
17205 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17206 )
17207 .await?;
17208 Ok(NodeResponse::Accepted)
17209 }
17210 NodeRequest::Paste { session, text } => {
17211 controlled_session(shared, connection_id, role, &session)?;
17212 validate_node_text("paste", &text)?;
17213 shared.require_session_runtime_policy(
17214 &session,
17215 ProviderRuntimeRequirement::SemanticPrompt,
17216 )?;
17217 shared
17218 .dispatch_input_bounded(
17219 &session,
17220 InputAction::InsertDraft(PromptPayload {
17221 text,
17222 framing: PromptFraming::BracketedPaste,
17223 }),
17224 )
17225 .await?;
17226 Ok(NodeResponse::Accepted)
17227 }
17228 NodeRequest::Input { session, text } => {
17229 controlled_session(shared, connection_id, role, &session)?;
17230 validate_node_text("terminal input", &text)?;
17231 shared
17232 .dispatch_input_bounded(
17233 &session,
17234 InputAction::TerminalText(TerminalText { text }),
17235 )
17236 .await?;
17237 Ok(NodeResponse::Accepted)
17238 }
17239 NodeRequest::TerminalBytes { session, bytes } => {
17240 controlled_session(shared, connection_id, role, &session)?;
17241 shared.sessionize_spawn(session.session.instance_id);
17242 let action = terminal_bytes_action(bytes)?;
17243 shared
17244 .dispatch_input_bounded(&session, action)
17245 .await?;
17246 Ok(NodeResponse::Accepted)
17247 }
17248 NodeRequest::TerminalControl { session, control } => {
17249 controlled_session(shared, connection_id, role, &session)?;
17250 shared
17251 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17252 .await?;
17253 Ok(NodeResponse::Accepted)
17254 }
17255 NodeRequest::Resize { session, size } => {
17256 controlled_session(shared, connection_id, role, &session)?;
17257 shared.sessionize_spawn(session.session.instance_id);
17258 if !size.is_valid() {
17259 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17260 }
17261 shared.dispatch_resize_bounded(&session, size).await?;
17262 Ok(NodeResponse::Accepted)
17263 }
17264 NodeRequest::Interrupt { session } => {
17265 controlled_session(shared, connection_id, role, &session)?;
17266 shared
17267 .dispatch_input_bounded(
17268 &session,
17269 InputAction::TerminalControl(TerminalControl::Interrupt),
17270 )
17271 .await?;
17272 Ok(NodeResponse::Accepted)
17273 }
17274 NodeRequest::Stop { session, force } => {
17275 controlled_session(shared, connection_id, role, &session)?;
17276 shared.sessionize_spawn(session.session.instance_id);
17277 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17278 shared.retire_session_grok_leader(session.session.instance_id);
17279 Ok(NodeResponse::Accepted)
17280 }
17281 NodeRequest::Remove { session } => {
17282 controlled_session(shared, connection_id, role, &session)?;
17283 shared.remove_session(&session).await?;
17284 Ok(NodeResponse::Accepted)
17285 }
17286 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17287 shared.require_controller(connection_id, role)?;
17288 let snapshot = shared.require_acp_session(&session)?;
17289 let interaction = snapshot
17290 .provider
17291 .interactions
17292 .iter()
17293 .find(|interaction| {
17294 interaction_correlation(
17295 session.session.instance_id,
17296 session.session.generation,
17297 interaction.id.0,
17298 ) == correlation_id
17299 })
17300 .ok_or_else(|| failure(
17301 NodeFailureCode::InvalidRequest,
17302 "unknown interaction correlation",
17303 ))?;
17304 if interaction.status != ProviderInteractionStatus::Pending {
17305 return Err(failure(
17306 NodeFailureCode::BackendOperationFailed,
17307 "interaction has already been resolved",
17308 ));
17309 }
17310 response
17311 .validate_for(interaction.interaction_kind)
17312 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17313 let interaction_id = interaction.id;
17314 let response_kind = response.kind();
17315 let response_outcome = response.outcome();
17316 shared.dispatch(ControlCommand::ResolveInteraction {
17317 instance_id: session.session.instance_id,
17318 generation: session.session.generation,
17319 interaction_id,
17320 response,
17321 })?;
17322 tracing::info!(
17328 node_id = %shared.node_id,
17329 session = ?session.session,
17330 correlation_id = %correlation_id,
17331 interaction_id = interaction_id.0,
17332 response_kind = ?response_kind,
17333 outcome = ?response_outcome,
17334 "interaction resolved",
17335 );
17336 Ok(NodeResponse::Accepted)
17337 }
17338 NodeRequest::SetSessionMode { session, mode_id } => {
17339 shared.require_controller(connection_id, role)?;
17340 shared.require_acp_session(&session)?;
17341 shared.dispatch(ControlCommand::SetSessionMode {
17342 instance_id: session.session.instance_id,
17343 mode_id,
17344 })?;
17345 Ok(NodeResponse::Accepted)
17346 }
17347 NodeRequest::SetSessionConfigOption {
17348 session,
17349 option_id,
17350 value_json,
17351 } => {
17352 shared.require_controller(connection_id, role)?;
17353 shared.require_acp_session(&session)?;
17354 shared.dispatch(ControlCommand::SetSessionConfigOption {
17355 instance_id: session.session.instance_id,
17356 option_id,
17357 value_json,
17358 })?;
17359 Ok(NodeResponse::Accepted)
17360 }
17361 NodeRequest::SetSessionModel { session, model_id } => {
17362 shared.require_controller(connection_id, role)?;
17363 shared.require_acp_session(&session)?;
17364 shared.dispatch(ControlCommand::SetSessionModel {
17365 instance_id: session.session.instance_id,
17366 model_id,
17367 })?;
17368 Ok(NodeResponse::Accepted)
17369 }
17370 NodeRequest::Shutdown => {
17371 shared.require_controller(connection_id, role)?;
17372 shared.begin_shutdown_locked().await?;
17373 Ok(NodeResponse::ShuttingDown)
17374 }
17375 }
17376}
17377
17378fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17379 shared.require_controller(connection_id, role)?;
17380 shared.validate_address(session)
17381}
17382
17383fn select_history_candidate(
17389 candidates: &[HistoryCandidateSummary],
17390 provider_session_id: Option<&str>,
17391) -> Result<String, NodeFailure> {
17392 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17393 Some(id) => candidate.session_id_hint == id,
17394 None => true,
17395 });
17396 let candidate = matches.next().ok_or_else(|| {
17397 failure(
17398 NodeFailureCode::SessionRecordConflict,
17399 if provider_session_id.is_some() {
17400 "no history candidate matches the managed provider session"
17401 } else {
17402 "no history candidate exists for the managed session"
17403 },
17404 )
17405 })?;
17406 if matches.next().is_some() {
17407 return Err(failure(
17408 NodeFailureCode::SessionRecordConflict,
17409 if provider_session_id.is_some() {
17410 "history identity is ambiguous for the managed provider session"
17411 } else {
17412 "history is ambiguous without a verified provider session identity"
17413 },
17414 ));
17415 }
17416 Ok(candidate.id.clone())
17417}
17418
17419fn resolved_spawn_profile_summary(
17420 profile: &SpawnProfileDefaults,
17421 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17422) -> Option<SpawnProfileSummary> {
17423 let environment_profile = match profile.environment_profile_id.as_ref() {
17424 None => None,
17425 Some(profile_id) => {
17426 let binding = environment_profiles.get(profile_id)?;
17427 if binding.provider != profile.provider
17428 || binding.native_profile_id(profile.mode).is_none()
17429 {
17430 return None;
17431 }
17432 Some(ResolvedEnvironmentProfileReceipt {
17433 profile_id: binding.id.clone(),
17434 profile_revision: binding.revision.clone(),
17435 network_allowlist: None,
17436 browser_profile_id: None,
17437 })
17438 }
17439 };
17440 Some(SpawnProfileSummary {
17441 id: profile.profile_id.clone(),
17442 revision: profile.revision.clone(),
17443 environment_profile,
17444 })
17445}
17446
17447fn session_record_export_target_matches(
17466 expected: &ManagedSessionRecord,
17467 current: &ManagedSessionRecord,
17468 session: &SessionAddress,
17469 allow_clean_detachment: bool,
17470) -> bool {
17471 if current == expected && current.active_session.as_ref() == Some(session) {
17472 return current.state == ManagedSessionState::Live;
17473 }
17474 allow_clean_detachment
17475 && current.state == ManagedSessionState::Dormant
17476 && current.active_session.is_none()
17477 && current.record_id == expected.record_id
17478 && current.display_name == expected.display_name
17479 && current.provider == expected.provider
17480 && current.mode == expected.mode
17481 && current.workspace_id == expected.workspace_id
17482 && current.canonical_root == expected.canonical_root
17483 && current.provider_session == expected.provider_session
17484 && current.environment_profile == expected.environment_profile
17485 && current.bundle == expected.bundle
17486 && current.context_id == expected.context_id
17487 && current.context == expected.context
17488 && current.exported_context == expected.exported_context
17489 && current.task_binding == expected.task_binding
17490 && current.created_at_unix_ms == expected.created_at_unix_ms
17491 && current.last_error == expected.last_error
17492}
17493
17494fn session_record_context_export_binding_is_exact(
17495 expected: &ManagedSessionRecord,
17496 current: &ManagedSessionRecord,
17497 identity: Option<&ProviderSessionIdentity>,
17498 session: &SessionAddress,
17499 binding: &SessionBinding,
17500 provider: &AgentId,
17501 current_root: &str,
17502 allow_clean_detachment: bool,
17503) -> bool {
17504 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17505 && current.provider_session.as_ref() == identity
17506 && current.workspace_id == session.workspace_id
17507 && provider == ¤t.provider
17508 && binding.workspace_id == session.workspace_id
17509 && binding.generation == session.session.generation
17510 && binding.record_id.as_ref() == Some(¤t.record_id)
17511 && platform::roots_equal(
17512 current_root,
17513 windows_path_text(¤t.canonical_root),
17514 )
17515}
17516
17517fn session_record_context_export_source_is_usable(
17518 record: &ManagedSessionRecord,
17519 status: &SessionStatus,
17520 allow_clean_detachment: bool,
17521) -> bool {
17522 (record.state == ManagedSessionState::Live
17523 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17524 && context_pack_source_status_is_usable(status)
17525}
17526
17527fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17528 PromptFraming::BracketedPaste
17529}
17530
17531fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17532 if text.len() > MAX_NODE_TEXT_BYTES {
17533 return Err(failure(
17534 NodeFailureCode::InvalidRequest,
17535 &format!(
17536 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17537 text.len(),
17538 ),
17539 ));
17540 }
17541 Ok(())
17542}
17543
17544fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17545 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17546 return Err(failure(
17547 NodeFailureCode::InvalidRequest,
17548 &format!(
17549 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17550 bytes.len(),
17551 ),
17552 ));
17553 }
17554 Ok(())
17555}
17556
17557fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17558 validate_terminal_bytes(&bytes)?;
17559 Ok(InputAction::TerminalBytes(bytes))
17560}
17561
17562fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17563 if root.is_empty()
17564 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17565 || root.chars().any(char::is_control)
17566 {
17567 return Err(failure(
17568 NodeFailureCode::InvalidWorkspaceRoot,
17569 &format!(
17570 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17571 ),
17572 ));
17573 }
17574 Ok(())
17575}
17576
17577fn canonical_standalone_workspace_candidate(
17578 workspace_id: &WorkspaceId,
17579 root: &str,
17580) -> Result<String, NodeServerError> {
17581 let path = Path::new(root);
17582 match std::fs::symlink_metadata(path) {
17583 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17584 .map(|workspace| workspace.canonical_root),
17585 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17586 if !path.is_absolute() {
17587 return Err(NodeServerError::InvalidWorkspaceRoot {
17588 workspace_id: workspace_id.clone(),
17589 path: root.to_owned(),
17590 message: "path is not absolute".to_owned(),
17591 });
17592 }
17593 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17594 workspace_id: workspace_id.clone(),
17595 path: root.to_owned(),
17596 message: "path has no standalone directory name".to_owned(),
17597 })?;
17598 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17599 workspace_id: workspace_id.clone(),
17600 path: root.to_owned(),
17601 message: "path has no parent directory".to_owned(),
17602 })?;
17603 let canonical = std::fs::canonicalize(parent)
17604 .map(|parent| parent.join(name))
17605 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17606 workspace_id: workspace_id.clone(),
17607 path: root.to_owned(),
17608 message: error.to_string(),
17609 })?;
17610 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17611 NodeServerError::InvalidWorkspaceRoot {
17612 workspace_id: workspace_id.clone(),
17613 path: path.to_string_lossy().into_owned(),
17614 message: "canonical path is not valid Unicode".to_owned(),
17615 }
17616 })?;
17617 let canonical_root = platform::normalize_canonical_root(canonical_root);
17618 validate_workspace_root(workspace_id, &canonical_root)?;
17619 Ok(canonical_root)
17620 }
17621 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17622 workspace_id: workspace_id.clone(),
17623 path: root.to_owned(),
17624 message: error.to_string(),
17625 }),
17626 }
17627}
17628
17629fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17630 let Some(revision) = revision else {
17631 return Ok(());
17632 };
17633 if revision.is_empty()
17634 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17635 || revision.chars().any(char::is_control)
17636 {
17637 return Err(failure(
17638 NodeFailureCode::InvalidRequest,
17639 &format!(
17640 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17641 ),
17642 ));
17643 }
17644 Ok(())
17645}
17646
17647fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17648 let code = match error.kind {
17649 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17650 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17651 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17652 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17653 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17654 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17655 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17656 };
17657 failure(code, &error.message)
17658}
17659
17660fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17661 let code = match error.kind {
17662 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17663 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17664 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17665 NodeFailureCode::BackendOperationFailed
17666 }
17667 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17668 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17669 }
17670 };
17671 failure(code, error.message)
17672}
17673
17674fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17675 let (code, message) = match error.kind {
17676 GitWorktreeErrorKind::Invalid => (
17677 NodeFailureCode::InvalidRequest,
17678 "managed Git input is invalid",
17679 ),
17680 GitWorktreeErrorKind::NotRepository => (
17681 NodeFailureCode::NotGitRepository,
17682 "managed source is not a Git repository",
17683 ),
17684 GitWorktreeErrorKind::Conflict => (
17685 NodeFailureCode::WorktreeConflict,
17686 "managed Git identity conflicts with existing repository state",
17687 ),
17688 GitWorktreeErrorKind::Protected => (
17689 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17690 "managed worktree ownership validation failed",
17691 ),
17692 GitWorktreeErrorKind::Dirty => (
17693 NodeFailureCode::WorktreeDirty,
17694 "managed worktree contains uncommitted changes",
17695 ),
17696 GitWorktreeErrorKind::Locked => (
17697 NodeFailureCode::WorktreeLocked,
17698 "managed worktree is locked",
17699 ),
17700 GitWorktreeErrorKind::Failed => (
17701 NodeFailureCode::BackendOperationFailed,
17702 "managed Git operation failed",
17703 ),
17704 };
17705 NodeFailure { code, message: message.to_owned() }
17706}
17707
17708fn managed_spawn_receipt(
17709 resolved: &ResolvedSpawnSpec,
17710 incarnation_id: NodeIncarnationId,
17711 session: SessionAddress,
17712 lease: ManagedWorktreeLeaseSnapshot,
17713 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17714 bundle: Option<ResolvedBundleReceipt>,
17715 context: Option<ResolvedContextPackReceipt>,
17716) -> ManagedWorktreeSpawnReceipt {
17717 let mut managed = resolved.clone();
17718 managed.target.worktree_id = Some(lease.workspace_id.clone());
17719 ManagedWorktreeSpawnReceipt {
17720 spawn: managed.receipt_with_materialization(
17721 incarnation_id,
17722 session,
17723 environment_profile,
17724 bundle,
17725 context,
17726 ),
17727 lease,
17728 }
17729}
17730
17731fn validate_selected_worktree(
17732 source_root: &str,
17733 selected_root: &str,
17734 worktrees: &[NativeGitWorktreeSnapshot],
17735) -> Result<(), NodeFailure> {
17736 if !worktrees
17737 .iter()
17738 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17739 {
17740 return Err(failure(
17741 NodeFailureCode::WorktreeProtected,
17742 "source workspace is not an authoritative non-bare Git worktree root",
17743 ));
17744 }
17745 if !worktrees.iter().any(|worktree| {
17746 worktree_paths_equal(&worktree.path, selected_root)
17747 && !worktree.is_main
17748 && !worktree.is_bare
17749 }) {
17750 return Err(failure(
17751 NodeFailureCode::WorktreeProtected,
17752 "selected workspace is not an eligible Git worktree in the source repository",
17753 ));
17754 }
17755 Ok(())
17756}
17757
17758fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17759 let code = match error.kind() {
17760 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17761 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17762 NodeFailureCode::RepositoryPathUnsafe
17763 }
17764 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17765 WorkspaceFileReadErrorKind::NotRegularFile => {
17766 NodeFailureCode::RepositoryFileNotRegular
17767 }
17768 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17769 NodeFailureCode::RepositoryFileReadFailed
17770 }
17771 WorkspaceFileReadErrorKind::RevisionConflict => {
17772 NodeFailureCode::RepositoryFileRevisionConflict
17773 }
17774 WorkspaceFileReadErrorKind::AlreadyExists
17775 | WorkspaceFileReadErrorKind::ParentNotFound
17776 | WorkspaceFileReadErrorKind::ParentNotDirectory
17777 | WorkspaceFileReadErrorKind::Canceled => {
17778 NodeFailureCode::RepositoryFileReadFailed
17779 }
17780 };
17781 failure(code, "repository file read failed")
17782}
17783
17784fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17785 let code = match error.kind() {
17786 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17787 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17788 NodeFailureCode::RepositoryPathUnsafe
17789 }
17790 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17791 WorkspaceFileReadErrorKind::NotRegularFile => {
17792 NodeFailureCode::RepositoryFileNotRegular
17793 }
17794 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17795 NodeFailureCode::RepositoryFileWriteFailed
17796 }
17797 WorkspaceFileReadErrorKind::RevisionConflict => {
17798 NodeFailureCode::RepositoryFileRevisionConflict
17799 }
17800 WorkspaceFileReadErrorKind::AlreadyExists
17801 | WorkspaceFileReadErrorKind::ParentNotFound
17802 | WorkspaceFileReadErrorKind::ParentNotDirectory
17803 | WorkspaceFileReadErrorKind::Canceled => {
17804 NodeFailureCode::RepositoryFileWriteFailed
17805 }
17806 };
17807 failure(code, "repository file write failed")
17808}
17809
17810async fn settle_workspace_entry_create<T>(
17811 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17812 commit_state: Arc<AtomicU8>,
17813 deadline: Duration,
17814 timeout_message: &'static str,
17815 task_failure_message: &'static str,
17816) -> Result<T, NodeFailure> {
17817 let joined = match timeout(deadline, &mut task).await {
17818 Ok(joined) => joined,
17819 Err(_) => {
17820 if commit_state
17821 .compare_exchange(
17822 WORKSPACE_ENTRY_CREATE_PENDING,
17823 WORKSPACE_ENTRY_CREATE_CANCELED,
17824 Ordering::AcqRel,
17825 Ordering::Acquire,
17826 )
17827 .is_ok()
17828 {
17829 return Err(failure(
17830 NodeFailureCode::RepositoryEntryCreateTimedOut,
17831 timeout_message,
17832 ));
17833 }
17834 debug_assert_eq!(
17835 commit_state.load(Ordering::Acquire),
17836 WORKSPACE_ENTRY_CREATE_COMMITTING,
17837 );
17838 task.await
17839 }
17840 };
17841 joined
17842 .map_err(|_| {
17843 failure(
17844 NodeFailureCode::RepositoryEntryCreateFailed,
17845 task_failure_message,
17846 )
17847 })?
17848 .map_err(workspace_entry_create_failure)
17849}
17850
17851fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17852 let code = match error.kind() {
17853 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17854 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17855 NodeFailureCode::RepositoryPathUnsafe
17856 }
17857 WorkspaceFileReadErrorKind::AlreadyExists => {
17858 NodeFailureCode::RepositoryEntryAlreadyExists
17859 }
17860 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17861 NodeFailureCode::RepositoryParentNotFound
17862 }
17863 WorkspaceFileReadErrorKind::ParentNotDirectory
17864 | WorkspaceFileReadErrorKind::NotRegularFile => {
17865 NodeFailureCode::RepositoryParentNotDirectory
17866 }
17867 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17868 NodeFailureCode::RepositoryEntryCreateFailed
17869 }
17870 WorkspaceFileReadErrorKind::RevisionConflict => {
17871 NodeFailureCode::RepositoryEntryCreateFailed
17872 }
17873 WorkspaceFileReadErrorKind::Canceled => {
17874 NodeFailureCode::RepositoryEntryCreateTimedOut
17875 }
17876 };
17877 failure(code, "repository entry create failed")
17878}
17879
17880fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17881 let value = digest(&SHA256, bytes)
17882 .as_ref()
17883 .iter()
17884 .map(|byte| format!("{byte:02x}"))
17885 .collect();
17886 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17887}
17888
17889fn managed_spawn_request_digest_v2(
17890 request: &ManagedWorktreeSpawnRequestV2,
17891) -> Result<String, NodeFailure> {
17892 let encoded = serde_json::to_vec(request).map_err(|_| {
17893 failure(
17894 NodeFailureCode::InvalidRequest,
17895 "managed worktree V2 request could not be canonicalized",
17896 )
17897 })?;
17898 let mut value = String::with_capacity(71);
17899 value.push_str("sha256:");
17900 for byte in digest(&SHA256, &encoded).as_ref() {
17901 use std::fmt::Write as _;
17902 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17903 }
17904 Ok(value)
17905}
17906
17907fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17908 let code = match error {
17909 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17910 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17911 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17912 NativeSessionPreviewError::UnsupportedProvider
17913 | NativeSessionPreviewError::PreviewUnavailable
17914 | NativeSessionPreviewError::SessionNotFound
17915 | NativeSessionPreviewError::AmbiguousSession => {
17916 NodeFailureCode::BackendOperationFailed
17917 }
17918 };
17919 failure(code, "native session preview failed")
17920}
17921
17922fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17923 let code = match error.kind() {
17924 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17925 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17926 };
17927 failure(code, "host directory browse failed")
17928}
17929
17930fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17931 let code = match error {
17932 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17933 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17934 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17935 NodeFailureCode::DeliveryStageConflict
17936 }
17937 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17938 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17939 NodeFailureCode::DeliveryChunkOutOfOrder
17940 }
17941 DeliveryStoreError::BlobDigestMismatch => {
17942 NodeFailureCode::DeliveryBlobDigestMismatch
17943 }
17944 DeliveryStoreError::BundleDigestMismatch => {
17945 NodeFailureCode::DeliveryBundleDigestMismatch
17946 }
17947 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17948 DeliveryStoreError::Unavailable
17949 | DeliveryStoreError::Corrupt
17950 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17951 };
17952 failure(code, "delivery operation failed")
17953}
17954
17955fn validate_workspace_root(
17956 workspace_id: &WorkspaceId,
17957 canonical_root: &str,
17958) -> Result<(), NodeServerError> {
17959 #[cfg(windows)]
17960 if canonical_root.starts_with(r"\\") {
17961 return Err(NodeServerError::InvalidWorkspaceRoot {
17962 workspace_id: workspace_id.clone(),
17963 path: canonical_root.to_owned(),
17964 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17965 });
17966 }
17967 if !platform::workspace_root_supported(canonical_root)
17968 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17969 {
17970 return Err(NodeServerError::InvalidWorkspaceRoot {
17971 workspace_id: workspace_id.clone(),
17972 path: canonical_root.to_owned(),
17973 message: "canonical path must be absolute, bounded, and free of control characters"
17974 .to_owned(),
17975 });
17976 }
17977 Ok(())
17978}
17979
17980fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17981 NodeFailure {
17982 code,
17983 message: node_failure_category(code).to_owned(),
17984 }
17985}
17986
17987fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17988 let code = match error {
17989 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17990 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17991 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17992 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17993 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17994 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17995 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17996 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17997 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17998 };
17999 failure(code, "harness MCP proxy operation failed")
18000}
18001
18002static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18006
18007fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18014 let trace = launch.trace.as_ref()?;
18015 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18016 if trace_dir.is_empty() {
18017 return None;
18018 }
18019 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18020 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18021 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18022}
18023
18024fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18025 let remaining = deadline.saturating_duration_since(Instant::now());
18026 if remaining.is_zero() {
18027 Err(failure(
18028 NodeFailureCode::SpawnDeadlineExceeded,
18029 "spawn deadline elapsed",
18030 ))
18031 } else {
18032 Ok(remaining)
18033 }
18034}
18035
18036fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18037 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18038 match deadline {
18039 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18040 None => Ok(bounded),
18041 }
18042}
18043
18044fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18045 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18046 failure(
18047 NodeFailureCode::SpawnDeadlineExceeded,
18048 "spawn dispatch exceeded the spawn deadline",
18049 )
18050 } else {
18051 error
18052 }
18053}
18054
18055fn spawn_runtime_capabilities(
18056 capabilities: &SpawnRequiredCapabilities,
18057) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18058 capabilities
18059 .iter()
18060 .map(|capability| match capability.as_str() {
18061 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18062 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18063 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18064 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18065 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18066 }
18067 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18068 _ => Err(failure(
18069 NodeFailureCode::UnsupportedSpawnCapability,
18070 "spawn required capability is unknown",
18071 )),
18072 })
18073 .collect()
18074}
18075
18076fn persistence_failure(_error: io::Error) -> NodeFailure {
18077 NodeFailure {
18078 code: NodeFailureCode::BackendOperationFailed,
18079 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18080 }
18081}
18082
18083fn node_failure_category(code: NodeFailureCode) -> &'static str {
18084 match code {
18085 NodeFailureCode::InvalidRequest => "invalid-request",
18086 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18087 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18088 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18089 NodeFailureCode::ReservationConflict => "reservation-conflict",
18090 NodeFailureCode::ReservationExpired => "reservation-expired",
18091 NodeFailureCode::BindingMismatch => "binding-mismatch",
18092 NodeFailureCode::NotActivated => "not-activated",
18093 NodeFailureCode::CallNotFound => "call-not-found",
18094 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18095 NodeFailureCode::ResponseTooLarge => "response-too-large",
18096 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18097 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18098 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18099 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18100 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18101 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18102 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18103 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18104 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18105 NodeFailureCode::Unauthorized => "unauthorized",
18106 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18107 NodeFailureCode::ControllerBusy => "controller-busy",
18108 NodeFailureCode::ControllerRequired => "controller-required",
18109 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18110 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18111 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18112 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18113 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18114 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18115 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18116 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18117 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18118 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18119 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18120 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18121 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18122 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18123 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18124 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18125 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18126 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18127 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18128 NodeFailureCode::GitReadFailed => "git-read-failed",
18129 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18130 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18131 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18132 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18133 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18134 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18135 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18136 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18137 NodeFailureCode::TurnInFlight => "turn-in-flight",
18138 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18139 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18140 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18141 "unsupported-network-allowlist-mapping"
18142 }
18143 NodeFailureCode::BrowserStationProbeUnavailable => {
18144 "browser-station-probe-unavailable"
18145 }
18146 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18147 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18148 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18149 "environment-profile-binding-mismatch"
18150 }
18151 NodeFailureCode::UnknownBundle => "unknown-bundle",
18152 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18153 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18154 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18155 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18156 NodeFailureCode::ContextPackMaterializationFailed => {
18157 "context-pack-materialization-failed"
18158 }
18159 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18160 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18161 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18162 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18163 NodeFailureCode::LastWorkspace => "last-workspace",
18164 NodeFailureCode::NotGitRepository => "not-git-repository",
18165 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18166 NodeFailureCode::WorktreeProtected => "worktree-protected",
18167 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18168 NodeFailureCode::WorktreeLocked => "worktree-locked",
18169 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18170 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18171 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18172 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18173 "managed-worktree-profile-revision-mismatch"
18174 }
18175 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18176 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18177 "standalone-workspace-recovery-required"
18178 }
18179 NodeFailureCode::UnknownSession => "unknown-session",
18180 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18181 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18182 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18183 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18184 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18185 NodeFailureCode::WorkspaceRegistrationRequired => {
18186 "workspace-registration-required"
18187 }
18188 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18189 NodeFailureCode::StaleGeneration => "stale-generation",
18190 NodeFailureCode::BackendBusy => "backend-busy",
18191 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18192 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18193 NodeFailureCode::ShuttingDown => "shutting-down",
18194 }
18195}
18196
18197fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18198 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18199}
18200
18201fn durable_state_load_error(error: io::Error) -> NodeServerError {
18202 let category = match session_registry::state_load_refusal(&error) {
18203 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18204 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18205 }
18206 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18207 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18208 }
18209 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18210 DURABLE_STATE_CONFLICT_ERROR
18211 }
18212 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18213 };
18214 durable_state_server_error(error, category)
18215}
18216
18217fn unix_time_ms() -> u64 {
18218 std::time::SystemTime::now()
18219 .duration_since(std::time::UNIX_EPOCH)
18220 .unwrap_or_default()
18221 .as_millis()
18222 .min(u64::MAX as u128) as u64
18223}
18224
18225fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18226 if record.provider_session.is_some() {
18227 ManagedSessionState::Dormant
18228 } else {
18229 ManagedSessionState::Unavailable
18230 }
18231}
18232
18233fn active_record_state(
18234 identity_admitted: bool,
18235 provider_session_is_known: bool,
18236) -> ManagedSessionState {
18237 if identity_admitted && !provider_session_is_known {
18238 ManagedSessionState::IdentityPending
18239 } else {
18240 ManagedSessionState::Live
18241 }
18242}
18243
18244fn managed_worktree_record_holders(
18245 records: &[ManagedSessionRecord],
18246) -> Vec<(SessionRecordId, WorkspaceId)> {
18247 records
18248 .iter()
18249 .filter(|record| record.provider_session.is_some())
18250 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18251 .collect()
18252}
18253
18254fn managed_spawn_record_ownership(
18255 binding: &SessionBinding,
18256 effective_runtime_policy: ProviderRuntimePolicy,
18257) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18258 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18259 if effective_runtime_policy.provider_session_identity {
18260 (binding.record_id.clone(), None)
18261 } else {
18262 (None, binding.record_id.clone())
18263 }
18264}
18265
18266#[derive(Debug, Error)]
18267pub enum NodeServerError {
18268 #[error("node endpoint must be a bounded absolute local transport path")]
18269 InvalidEndpoint,
18270 #[error("node access token must contain 1..=4096 bytes")]
18271 InvalidAccessToken,
18272 #[error("node HTTP observer must listen on a loopback address: {0}")]
18273 InvalidApiListen(std::net::SocketAddr),
18274 #[error("node browser bridge must listen on a loopback address: {0}")]
18275 InvalidBridgeListen(std::net::SocketAddr),
18276 #[error("node browser bridge token must contain 1..=4096 bytes")]
18277 InvalidBridgeToken,
18278 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18279 BridgeUnderlayRequiresBridgeListen,
18280 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18281 BridgeUnderlayRequiresBridgeToken,
18282 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18283 InvalidBridgeUnderlayToken,
18284 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18285 InvalidBridgeUnderlayKey,
18286 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18287 InvalidCallHome(std::net::SocketAddr),
18288 #[error("node could not reach its call-home relay: {0}")]
18289 CallHomeTransport(#[source] std::io::Error),
18290 #[error("node durable state path must be an absolute file path: {0}")]
18291 InvalidStatePath(String),
18292 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18293 InvalidHarnessMcpHelper,
18294 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18295 InvalidNetworkAllowlistCatalogPath(String),
18296 #[error("network allowlist catalog could not be read: {0}")]
18297 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18298 #[error("network allowlist catalog line {line}: {message}")]
18299 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18300 #[error("network allowlist catalog: {0}")]
18301 InvalidNetworkAllowlistCatalog(String),
18302 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18303 NetworkAllowlistCatalogCapacity { max: usize },
18304 #[error("the local state directory is unavailable; supply an explicit state path")]
18305 LocalStateDirectoryUnavailable,
18306 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18307 LocalRuntimeDirectoryUnavailable,
18308 #[error("node durable state failed: {0}")]
18309 DurableState(io::Error),
18310 #[error("node requires at least one configured workspace")]
18311 NoWorkspaces,
18312 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18313 InvalidWorkspaceRoot {
18314 workspace_id: WorkspaceId,
18315 path: String,
18316 message: String,
18317 },
18318 #[error("duplicate workspace ID: {0}")]
18319 DuplicateWorkspaceId(WorkspaceId),
18320 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18321 DuplicateWorkspaceRoot {
18322 first: WorkspaceId,
18323 second: WorkspaceId,
18324 root: String,
18325 },
18326 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18327 ManagedWorktreeProfileRequired(WorkspaceId),
18328 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18329 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18330 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18331 DuplicateManagedWorktreeProfile {
18332 workspace_id: WorkspaceId,
18333 profile_id: WorktreeProfileId,
18334 },
18335 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18336 ManagedWorktreeProfileCapacity {
18337 workspace_id: WorkspaceId,
18338 max: usize,
18339 },
18340 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18341 InvalidManagedWorktreeProfile {
18342 workspace_id: WorkspaceId,
18343 profile_id: WorktreeProfileId,
18344 message: String,
18345 },
18346 #[error("duplicate node environment profile: {0}")]
18347 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18348 #[error("node environment profile provider is unavailable: {0}")]
18349 EnvironmentProfileProviderUnavailable(AgentId),
18350 #[error("node environment profile capacity is {max}")]
18351 EnvironmentProfileCapacity { max: usize },
18352 #[error("node environment profiles reuse native profile ID '{0}'")]
18353 DuplicateNativeEnvironmentProfile(String),
18354 #[error("node native environment profile is invalid: {0}")]
18355 NativeEnvironmentProfile(String),
18356 #[error("node session-environment materialization root must be absolute")]
18357 InvalidSessionEnvironmentRoot,
18358 #[error("node session-environment materializer is unavailable")]
18359 SessionEnvironmentMaterializerRequired,
18360 #[error("node session-environment materializer failed to initialize")]
18361 SessionEnvironmentMaterializer,
18362 #[error("node bundle catalog is invalid: {0}")]
18363 BundleCatalog(String),
18364 #[error("node delivery store failed to initialize: {0}")]
18365 DeliveryStore(String),
18366 #[error("node context pack store failed to initialize")]
18367 ContextPackStore,
18368 #[error("active agent registry failed: {0}")]
18369 Registry(String),
18370 #[error("node provider contract manifest is invalid: {0}")]
18371 ProviderContractManifest(String),
18372 #[error("named pipe I/O failed: {0}")]
18373 Io(#[from] io::Error),
18374 #[error("node HTTP observer failed: {0}")]
18375 HttpApi(io::Error),
18376 #[error("node browser bridge failed: {0}")]
18377 BridgeApi(io::Error),
18378 #[error(transparent)]
18379 Frame(#[from] FrameError),
18380 #[error("node handshake failed: {0}")]
18381 Handshake(String),
18382 #[error("node authentication frame was not received before the bounded deadline")]
18383 AuthenticationTimedOut,
18384 #[error("node authentication primitive failed: {0}")]
18385 Authentication(String),
18386 #[error("node incarnation identity generation failed: {0}")]
18387 IncarnationIdentity(String),
18388 #[error("node pre-authentication limiter is closed")]
18389 PreauthClosed,
18390 #[error("node authenticated connection limit was reached")]
18391 AuthenticatedConnectionLimit,
18392 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18393 SignalStreamClosed,
18394 #[error("node stop-all dispatch failed: {0:?}")]
18395 ShutdownDispatch(NodeFailure),
18396 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18397 ShutdownTimedOut { active_native_sessions: usize },
18398}
18399
18400impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18401 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18402 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18403 match error {
18404 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18405 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18406 E::InvalidEntry { line, message } => {
18407 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18408 }
18409 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18410 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18411 }
18412 }
18413}
18414
18415#[cfg(test)]
18416mod agent_stream_blocked_tests {
18417 use super::*;
18418 use gate4agent_types::{
18419 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18420 ProviderSource,
18421 };
18422
18423 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18424 ProviderSource {
18425 family,
18426 binding: AdapterBinding::new(
18427 AdapterId::new(adapter).unwrap(),
18428 "fixture/v1",
18429 AdapterVerification::SyntheticFixture,
18430 )
18431 .unwrap(),
18432 }
18433 }
18434
18435 fn provider_control_event_at(
18436 family: AdapterFamily,
18437 adapter: &str,
18438 source_sequence: u64,
18439 provider_event: ProviderEvent,
18440 ) -> ControlEvent {
18441 ControlEvent {
18442 sequence: 41,
18443 command_id: None,
18444 instance_id: AgentInstanceId(7),
18445 generation: SessionGeneration(3),
18446 event: ControlEventKind::ProviderEvent {
18447 sequence: 8,
18448 source: provider_source(family, adapter),
18449 source_sequence,
18450 event: provider_event,
18451 },
18452 }
18453 }
18454
18455 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18456 provider_control_event_at(family, "fixture", 9, provider_event)
18457 }
18458
18459 fn denied_host_request(
18460 by: ProviderHostDecisionAuthority,
18461 reason: Option<String>,
18462 ) -> ControlEvent {
18463 provider_control_event_at(
18464 AdapterFamily::Acp,
18465 "claude-code",
18466 2,
18467 ProviderEvent::HostRequestObserved {
18468 method: "terminal/create".to_owned(),
18469 params_json: String::new(),
18470 decision: ProviderHostRequestDecision::Denied { by },
18471 outcome: ProviderHostRequestOutcome::Executed,
18472 reason,
18473 },
18474 )
18475 }
18476
18477 fn blocked(
18478 event: &ControlEvent,
18479 ) -> (
18480 Option<String>,
18481 String,
18482 BlockAuthorityV1,
18483 Option<String>,
18484 String,
18485 Option<String>,
18486 ) {
18487 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18488 chunk.validate().expect("minted chunk must validate");
18489 let AgentStreamChunkKindV1::Blocked {
18490 correlation_id,
18491 tool_class,
18492 authority,
18493 reason_kind,
18494 reason,
18495 help,
18496 } = chunk.kind
18497 else {
18498 panic!("expected AgentStreamChunkKindV1::Blocked");
18499 };
18500 (correlation_id, tool_class, authority, reason_kind, reason, help)
18501 }
18502
18503 #[test]
18507 fn the_enabled_provider_matrix_is_exact() {
18508 let registry = active_registry().unwrap();
18509 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18510 assert_eq!(
18511 providers
18512 .iter()
18513 .map(|contract| contract.provider.as_str())
18514 .collect::<Vec<_>>(),
18515 vec!["shell", "claude", "codex", "grok", "kimi"],
18517 );
18518 assert!(adapters.iter().all(|contract| {
18519 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18520 }));
18521 }
18522
18523 #[test]
18526 fn gate_denied_host_request_mints_a_blocked_chunk() {
18527 let gate_text =
18528 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18529 let event = denied_host_request(
18530 ProviderHostDecisionAuthority::Gate,
18531 Some(gate_text.to_owned()),
18532 );
18533 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18534 assert_eq!(chunk.source_sequence, 2);
18535 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18536 assert_eq!(authority, BlockAuthorityV1::HostGate);
18537 assert_eq!(reason, gate_text);
18538 assert_eq!(correlation_id, None);
18539 assert_eq!(reason_kind, None);
18540 assert_eq!(help, None);
18541 assert_eq!(tool_class, "Write");
18542 }
18543
18544 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.";
18545
18546 #[test]
18552 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18553 let event = provider_control_event(
18554 AdapterFamily::Acp,
18555 ProviderEvent::ToolCompleted {
18556 id: "toolu_9".to_owned(),
18557 output: CLASSIFIER_SAMPLE.to_owned(),
18558 is_error: true,
18559 duration_ms: None,
18560 agent_id: None,
18561 non_execution_kind: Some("permission-rule".to_owned()),
18562 },
18563 );
18564 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18565 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18566 assert_eq!(reason, "Blocked by classifier");
18567 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18568 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18569 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18570 }
18571
18572 #[test]
18577 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18578 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.";
18579 let event = provider_control_event(
18580 AdapterFamily::Acp,
18581 ProviderEvent::SessionEnded {
18582 result: "JSON-RPC error -32603: Internal error".to_owned(),
18583 cost_usd: None,
18584 is_error: true,
18585 stop_reason: Some(ProviderStopReason::ProviderError {
18586 code: -32603,
18587 message: message.to_owned(),
18588 vendor_code: Some("usageLimitExceeded".to_owned()),
18589 }),
18590 },
18591 );
18592 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18593 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18594 assert_eq!(reason, message);
18595 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18596 assert_eq!(tool_class, "Session");
18597 }
18598
18599 #[test]
18602 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18603 let event = provider_control_event(
18604 AdapterFamily::Acp,
18605 ProviderEvent::SessionEnded {
18606 result: "refusal".to_owned(),
18607 cost_usd: None,
18608 is_error: true,
18609 stop_reason: Some(ProviderStopReason::Refusal),
18610 },
18611 );
18612 let (_, _, authority, _, _, _) = blocked(&event);
18613 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18614 }
18615
18616 #[test]
18620 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18621 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18622 let event = provider_control_event(
18623 AdapterFamily::Acp,
18624 ProviderEvent::ToolCompleted {
18625 id: "toolu_1".to_owned(),
18626 output: "command exited with status 1".to_owned(),
18627 is_error: true,
18628 duration_ms: Some(12),
18629 agent_id: None,
18630 non_execution_kind,
18631 },
18632 );
18633 assert!(agent_stream_chunk(&event).is_none());
18634 }
18635 let narrative = provider_control_event(
18636 AdapterFamily::Acp,
18637 ProviderEvent::ToolCompleted {
18638 id: "toolu_2".to_owned(),
18639 output: "the harness blocked the write, approval needed".to_owned(),
18640 is_error: false,
18641 duration_ms: Some(3),
18642 agent_id: None,
18643 non_execution_kind: None,
18644 },
18645 );
18646 assert!(agent_stream_chunk(&narrative).is_none());
18647 }
18648
18649 #[test]
18653 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18654 for (decision, outcome) in [
18655 (
18656 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18657 ProviderHostRequestOutcome::Executed,
18658 ),
18659 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18660 (
18661 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18662 ProviderHostRequestOutcome::Failed {
18663 error: "terminal/create spawn failed: os error 3".to_owned(),
18664 },
18665 ),
18666 ] {
18667 let event = provider_control_event_at(
18668 AdapterFamily::Acp,
18669 "claude-code",
18670 3,
18671 ProviderEvent::HostRequestObserved {
18672 method: "fs/read_text_file".to_owned(),
18673 params_json: String::new(),
18674 decision: decision.clone(),
18675 outcome,
18676 reason: None,
18677 },
18678 );
18679 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18680 }
18681 }
18682
18683 #[test]
18687 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18688 for (by, expected_authority, expected_reason) in [
18689 (
18690 ProviderHostDecisionAuthority::Operator,
18691 BlockAuthorityV1::Operator,
18692 "declined by the operator",
18693 ),
18694 (
18695 ProviderHostDecisionAuthority::DeadlinePolicy,
18696 BlockAuthorityV1::HostDeadline,
18697 "denied by host policy after the operator deadline expired",
18698 ),
18699 ] {
18700 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18701 assert_eq!(authority, expected_authority);
18702 assert_eq!(reason, expected_reason);
18703 }
18704 }
18705
18706 #[test]
18710 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18711 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18712 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18713 ProviderHostDecisionAuthority::Policy,
18714 Some(oversized),
18715 ));
18716 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18717 }
18718}
18719
18720#[cfg(test)]
18721mod standalone_capability_platform_tests {
18722 use super::*;
18723
18724 #[test]
18725 fn standalone_workspace_capability_requires_windows_identity_guards() {
18726 let capabilities = baseline_capabilities().unwrap();
18727 let advertised = capabilities.iter().any(|capability| {
18728 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18729 });
18730 assert_eq!(advertised, cfg!(windows));
18731
18732 let request = NodeRequest::CreateStandaloneWorkspace {
18733 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18734 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18735 initial_branch: None,
18736 };
18737 assert_eq!(
18738 request_uses_unnegotiated_capability(&request, &capabilities),
18739 !cfg!(windows),
18740 );
18741 }
18742}
18743
18744
18745#[cfg(test)]
18746mod station_knobs_resolve_tests {
18747 use super::*;
18748 use gate4agent_catalog::EnvMutation;
18749 use gate4agent_runtime_native::{
18750 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18751 NativeLaunchProfileId,
18752 };
18753 use std::ffi::OsString;
18754 use std::sync::Arc;
18755
18756 struct EmptyEnvironmentResolver;
18757
18758 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18759 fn resolve_child_environment(
18760 &self,
18761 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18762 Ok(vec![EnvMutation {
18763 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18764 value: None,
18765 }])
18766 }
18767 }
18768
18769 fn agent(value: &str) -> AgentId {
18770 AgentId::new(value).unwrap()
18771 }
18772
18773 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18774 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18775 let profile_revision =
18776 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18777 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18778 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18779 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18780 provider: agent("claude"),
18781 mode: SessionMode::Pty,
18782 terminal_size: gate4agent_types::TerminalSize {
18783 rows: 24,
18784 columns: 80,
18785 },
18786 prompt: None,
18787 bundle_id: None,
18788 context_id: None,
18789 environment_profile_id: Some(profile_id.clone()),
18790 };
18791 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18792 let catalog = active_registry().unwrap();
18793 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18794 let control = runtime.native_launch_profile_control();
18795 let node_profile = NodeEnvironmentProfile::new(
18796 profile_id.clone(),
18797 profile_revision,
18798 agent("claude"),
18799 [NativeLaunchProfile::new(
18800 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18801 agent("claude"),
18802 TransportKind::Pty,
18803 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18804 Arc::new(EmptyEnvironmentResolver),
18805 )
18806 .unwrap()],
18807 )
18808 .unwrap();
18809 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18810 for native_profile in native_profiles {
18811 runtime.upsert_native_launch_profile(native_profile).unwrap();
18812 }
18813 let workspace = WorkspaceConfig::new(
18814 WorkspaceId::new("primary").unwrap(),
18815 std::env::current_dir().unwrap(),
18816 )
18817 .unwrap();
18818 let shared = NodeShared::new_with_incarnation(
18819 handle,
18820 "fixture-token".to_owned(),
18821 NodeId::new("node-station-knobs-test").unwrap(),
18822 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18823 vec![workspace],
18824 vec![agent("claude")],
18825 ProviderRuntimeStatuses::default(),
18826 None,
18827 Vec::new(),
18828 Vec::new(),
18829 spawn_profiles,
18830 Some(control),
18831 None,
18832 None,
18833 None,
18834 Vec::new(),
18835 Vec::new(),
18836 Vec::new(),
18837 Vec::new(),
18838 None,
18839 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18840 );
18841 shared
18842 .environment_profiles
18843 .write()
18844 .unwrap_or_else(|poisoned| poisoned.into_inner())
18845 .insert(binding.id.clone(), binding);
18846 (shared, spawn_defaults)
18847 }
18848
18849 #[test]
18852 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18853 let (shared, defaults) = station_knobs_fixture();
18854 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18855 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18856 let spec = crate::protocol::SpawnSpec {
18857 target: crate::protocol::SpawnTarget {
18858 node_id: shared.node_id.clone(),
18859 workspace_id: WorkspaceId::new("primary").unwrap(),
18860 worktree_id: None,
18861 },
18862 profile_id: defaults.profile_id.clone(),
18863 expected_profile_revision: defaults.revision.clone(),
18864 overrides: crate::protocol::SpawnOverrides {
18865 network_allowlist: Some(unknown),
18866 browser_profile_id: Some(browser),
18867 ..crate::protocol::SpawnOverrides::default()
18868 },
18869 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18870 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18871 "station-knobs-unknown-allowlist",
18872 )
18873 .unwrap(),
18874 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18875 };
18876 let resolved = spec.resolve(&defaults).unwrap();
18877 let failure = shared
18878 .resolve_environment_profile(&resolved)
18879 .expect_err("unknown allowlist must refuse");
18880 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18881 }
18882
18883 #[test]
18889 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18890 let (shared, defaults) = station_knobs_fixture();
18891 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18892 #[cfg(not(feature = "dig2-station-probe"))]
18893 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18894 shared
18895 .network_allowlist_catalog
18896 .write()
18897 .unwrap_or_else(|poisoned| poisoned.into_inner())
18898 .insert_id(allowlist.clone())
18899 .unwrap();
18900 let spec = crate::protocol::SpawnSpec {
18901 target: crate::protocol::SpawnTarget {
18902 node_id: shared.node_id.clone(),
18903 workspace_id: WorkspaceId::new("primary").unwrap(),
18904 worktree_id: None,
18905 },
18906 profile_id: defaults.profile_id.clone(),
18907 expected_profile_revision: defaults.revision.clone(),
18908 overrides: crate::protocol::SpawnOverrides {
18909 network_allowlist: Some(allowlist.clone()),
18910 #[cfg(not(feature = "dig2-station-probe"))]
18911 browser_profile_id: Some(browser.clone()),
18912 ..crate::protocol::SpawnOverrides::default()
18913 },
18914 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18915 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18916 .unwrap(),
18917 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18918 };
18919 let resolved = spec.resolve(&defaults).unwrap();
18920 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18921 #[cfg(not(feature = "dig2-station-probe"))]
18922 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18923 let receipt = shared
18924 .resolve_environment_profile(&resolved)
18925 .expect("resolve ok")
18926 .expect("env profile present");
18927 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18928 #[cfg(not(feature = "dig2-station-probe"))]
18929 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18930 #[cfg(feature = "dig2-station-probe")]
18931 assert_eq!(receipt.browser_profile_id, None);
18932 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18933
18934 let omitted = crate::protocol::SpawnSpec {
18935 overrides: crate::protocol::SpawnOverrides::default(),
18936 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18937 .unwrap(),
18938 ..spec
18939 };
18940 let resolved_omit = omitted.resolve(&defaults).unwrap();
18941 let bare = shared
18942 .resolve_environment_profile(&resolved_omit)
18943 .expect("resolve ok")
18944 .expect("env profile present");
18945 assert_eq!(bare.network_allowlist, None);
18946 assert_eq!(bare.browser_profile_id, None);
18947 }
18948
18949 #[cfg(feature = "dig2-station-probe")]
18954 #[test]
18955 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18956 let (shared, defaults) = station_knobs_fixture();
18957 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18958 let spec = crate::protocol::SpawnSpec {
18959 target: crate::protocol::SpawnTarget {
18960 node_id: shared.node_id.clone(),
18961 workspace_id: WorkspaceId::new("primary").unwrap(),
18962 worktree_id: None,
18963 },
18964 profile_id: defaults.profile_id.clone(),
18965 expected_profile_revision: defaults.revision.clone(),
18966 overrides: crate::protocol::SpawnOverrides {
18967 browser_profile_id: Some(browser),
18968 ..crate::protocol::SpawnOverrides::default()
18969 },
18970 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18971 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18972 "station-knobs-probe-refuse",
18973 )
18974 .unwrap(),
18975 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18976 };
18977 let resolved = spec.resolve(&defaults).unwrap();
18978 let failure = shared
18979 .resolve_environment_profile(&resolved)
18980 .expect_err("browser_profile with failing probe must refuse");
18981 #[cfg(windows)]
18982 {
18983 assert!(
18984 matches!(
18985 failure.code,
18986 NodeFailureCode::BrowserStationUnreachable
18987 | NodeFailureCode::BrowserStationProbeUnavailable
18988 ),
18989 "unexpected code {:?}",
18990 failure.code
18991 );
18992 }
18993 #[cfg(not(windows))]
18994 {
18995 assert_eq!(
18996 failure.code,
18997 NodeFailureCode::BrowserStationProbeUnavailable
18998 );
18999 }
19000 }
19001
19002 #[cfg(feature = "dig2-station-probe")]
19004 #[test]
19005 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19006 let (shared, defaults) = station_knobs_fixture();
19007 let spec = crate::protocol::SpawnSpec {
19008 target: crate::protocol::SpawnTarget {
19009 node_id: shared.node_id.clone(),
19010 workspace_id: WorkspaceId::new("primary").unwrap(),
19011 worktree_id: None,
19012 },
19013 profile_id: defaults.profile_id.clone(),
19014 expected_profile_revision: defaults.revision.clone(),
19015 overrides: crate::protocol::SpawnOverrides::default(),
19016 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19017 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19018 "station-knobs-probe-omit",
19019 )
19020 .unwrap(),
19021 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19022 };
19023 let resolved = spec.resolve(&defaults).unwrap();
19024 let receipt = shared
19025 .resolve_environment_profile(&resolved)
19026 .expect("omit browser_profile must not probe")
19027 .expect("env profile present");
19028 assert_eq!(receipt.browser_profile_id, None);
19029 }
19030
19031 #[cfg(feature = "dig2-station-probe")]
19035 #[test]
19036 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19037 let (shared, _defaults) = station_knobs_fixture();
19038 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19039 let workspace_id = WorkspaceId::new("primary").unwrap();
19040 let address_a = SessionAddress {
19041 workspace_id: workspace_id.clone(),
19042 session: SessionKey {
19043 instance_id: AgentInstanceId(101),
19044 generation: SessionGeneration(1),
19045 },
19046 };
19047 let address_b = SessionAddress {
19048 workspace_id: workspace_id.clone(),
19049 session: SessionKey {
19050 instance_id: AgentInstanceId(102),
19051 generation: SessionGeneration(1),
19052 },
19053 };
19054 let guard_a = crate::dig2_station_lease::acquire_lease(
19055 &shared.browser_station_leases,
19056 profile.clone(),
19057 address_a.session.instance_id,
19058 1,
19059 )
19060 .expect("first lease");
19061 let busy = crate::dig2_station_lease::acquire_lease(
19062 &shared.browser_station_leases,
19063 profile.clone(),
19064 address_b.session.instance_id,
19065 2,
19066 )
19067 .expect_err("second holder must refuse");
19068 assert!(matches!(
19069 busy,
19070 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19071 ));
19072 shared.bind_session_with_policy(
19074 &address_a,
19075 ProviderRuntimePolicy::raw_pty(),
19076 None,
19077 );
19078 guard_a.retain();
19079 assert!(shared.remove_binding(&address_a).is_some());
19080 crate::dig2_station_lease::acquire_lease(
19081 &shared.browser_station_leases,
19082 profile,
19083 address_b.session.instance_id,
19084 3,
19085 )
19086 .expect("reacquire after clear")
19087 .retain();
19088 assert!(
19089 crate::dig2_station_lease::release_lease_for_holder(
19090 &shared.browser_station_leases,
19091 address_b.session.instance_id,
19092 )
19093 );
19094 }
19095
19096 #[cfg(not(feature = "dig2-station-probe"))]
19099 #[test]
19100 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19101 let (shared, defaults) = station_knobs_fixture();
19102 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19103 let spec = crate::protocol::SpawnSpec {
19104 target: crate::protocol::SpawnTarget {
19105 node_id: shared.node_id.clone(),
19106 workspace_id: WorkspaceId::new("primary").unwrap(),
19107 worktree_id: None,
19108 },
19109 profile_id: defaults.profile_id.clone(),
19110 expected_profile_revision: defaults.revision.clone(),
19111 overrides: crate::protocol::SpawnOverrides {
19112 browser_profile_id: Some(browser.clone()),
19113 ..crate::protocol::SpawnOverrides::default()
19114 },
19115 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19116 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19117 .unwrap(),
19118 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19119 };
19120 let resolved = spec.resolve(&defaults).unwrap();
19121 let receipt = shared
19122 .resolve_environment_profile(&resolved)
19123 .expect("stub resolve")
19124 .expect("env profile");
19125 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19126 }
19127
19128
19129 #[test]
19132 fn network_allowlist_catalog_loads_from_temp_file() {
19133 let unique = std::time::SystemTime::now()
19134 .duration_since(std::time::UNIX_EPOCH)
19135 .unwrap()
19136 .as_nanos();
19137 let path = std::env::temp_dir().join(format!(
19138 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19139 std::process::id(),
19140 ));
19141 std::fs::write(
19142 &path,
19143 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19144 )
19145 .unwrap();
19146 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19147 .expect("temp catalog file must load");
19148 let _ = std::fs::remove_file(&path);
19149 assert_eq!(loaded.len(), 2);
19150 let (shared, defaults) = station_knobs_fixture();
19151 {
19152 let mut catalog = shared
19153 .network_allowlist_catalog
19154 .write()
19155 .unwrap_or_else(|poisoned| poisoned.into_inner());
19156 *catalog = loaded;
19157 }
19158 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19159 let spec = crate::protocol::SpawnSpec {
19160 target: crate::protocol::SpawnTarget {
19161 node_id: shared.node_id.clone(),
19162 workspace_id: WorkspaceId::new("primary").unwrap(),
19163 worktree_id: None,
19164 },
19165 profile_id: defaults.profile_id.clone(),
19166 expected_profile_revision: defaults.revision.clone(),
19167 overrides: crate::protocol::SpawnOverrides {
19168 network_allowlist: Some(allowlist.clone()),
19169 ..crate::protocol::SpawnOverrides::default()
19170 },
19171 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19172 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19173 "station-knobs-catalog-file",
19174 )
19175 .unwrap(),
19176 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19177 };
19178 let resolved = spec.resolve(&defaults).unwrap();
19179 let receipt = shared
19180 .resolve_environment_profile(&resolved)
19181 .expect("registered via file load")
19182 .expect("env profile present");
19183 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19184 }
19185
19186 #[test]
19189 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19190 let (shared, _defaults) = station_knobs_fixture();
19191 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19192 shared
19193 .network_allowlist_catalog
19194 .write()
19195 .unwrap_or_else(|poisoned| poisoned.into_inner())
19196 .insert_id(allowlist.clone())
19197 .unwrap();
19198 let snapshot = shared.snapshot();
19199 let inventory = snapshot
19200 .launch_inventory
19201 .expect("launch inventory present");
19202 let ids = inventory
19203 .network_allowlists
19204 .expect("non-empty catalog surfaces ids");
19205 assert_eq!(ids, vec![allowlist]);
19206 }
19207
19208 #[test]
19212 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19213 let (shared, defaults) = station_knobs_fixture();
19214 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19215 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19216 id: allowlist.clone(),
19217 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19218 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19219 peer: "127.0.0.1:443".to_owned(),
19220 }],
19221 provider_native: Some(
19222 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19223 codex_network_access: Some(true),
19224 },
19225 ),
19226 };
19227 shared
19228 .network_allowlist_catalog
19229 .write()
19230 .unwrap_or_else(|poisoned| poisoned.into_inner())
19231 .insert(entry)
19232 .unwrap();
19233 let spec = crate::protocol::SpawnSpec {
19234 target: crate::protocol::SpawnTarget {
19235 node_id: shared.node_id.clone(),
19236 workspace_id: WorkspaceId::new("primary").unwrap(),
19237 worktree_id: None,
19238 },
19239 profile_id: defaults.profile_id.clone(),
19240 expected_profile_revision: defaults.revision.clone(),
19241 overrides: crate::protocol::SpawnOverrides {
19242 network_allowlist: Some(allowlist),
19243 ..crate::protocol::SpawnOverrides::default()
19244 },
19245 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19246 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19247 "station-knobs-unsupported-mapping",
19248 )
19249 .unwrap(),
19250 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19251 };
19252 let resolved = spec.resolve(&defaults).unwrap();
19253 assert_eq!(resolved.provider.as_str(), "claude");
19254 let failure = shared
19255 .resolve_environment_profile(&resolved)
19256 .expect_err("claude + codex_network_access must refuse");
19257 assert_eq!(
19258 failure.code,
19259 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19260 );
19261 }
19262
19263 #[test]
19267 fn resolve_codex_network_access_overlay_moderate_only() {
19268 let (shared, defaults) = station_knobs_fixture();
19269 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19270 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19271 id: allowlist.clone(),
19272 permits: Vec::new(),
19273 provider_native: Some(
19274 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19275 codex_network_access: Some(true),
19276 },
19277 ),
19278 };
19279 shared
19280 .network_allowlist_catalog
19281 .write()
19282 .unwrap_or_else(|poisoned| poisoned.into_inner())
19283 .insert(entry)
19284 .unwrap();
19285
19286 let overlay = shared
19287 .resolve_codex_network_access_overlay_args(
19288 &agent("codex"),
19289 SessionMode::Pty,
19290 ApprovalLevel::Moderate,
19291 Some(&allowlist),
19292 )
19293 .expect("Moderate Codex maps network_access");
19294 assert_eq!(
19295 overlay,
19296 vec![
19297 std::ffi::OsString::from("-c"),
19298 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19299 ]
19300 );
19301
19302 let acp = shared
19303 .resolve_codex_network_access_overlay_args(
19304 &agent("codex"),
19305 SessionMode::Acp,
19306 ApprovalLevel::Moderate,
19307 Some(&allowlist),
19308 )
19309 .unwrap();
19310 assert_eq!(acp, overlay);
19311
19312 let inline = shared
19313 .resolve_codex_network_access_overlay_args(
19314 &agent("codex"),
19315 SessionMode::Inline,
19316 ApprovalLevel::Moderate,
19317 Some(&allowlist),
19318 )
19319 .unwrap_err();
19320 assert_eq!(
19321 inline.code,
19322 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19323 );
19324
19325 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19326 let spec = crate::protocol::SpawnSpec {
19327 target: crate::protocol::SpawnTarget {
19328 node_id: shared.node_id.clone(),
19329 workspace_id: WorkspaceId::new("primary").unwrap(),
19330 worktree_id: None,
19331 },
19332 profile_id: defaults.profile_id.clone(),
19333 expected_profile_revision: defaults.revision.clone(),
19334 overrides: crate::protocol::SpawnOverrides {
19335 provider: crate::protocol::SpawnOverride::Set {
19336 value: agent("codex"),
19337 },
19338 approval_level: Some(level),
19339 network_allowlist: Some(allowlist.clone()),
19340 ..crate::protocol::SpawnOverrides::default()
19341 },
19342 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19343 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19344 "codex-net-refuse-{}",
19345 match level {
19346 ApprovalLevel::ReadOnly => "ro",
19347 ApprovalLevel::FullAuto => "fa",
19348 _ => "other",
19349 }
19350 ))
19351 .unwrap(),
19352 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19353 };
19354 let resolved = spec.resolve(&defaults).unwrap();
19355 let failure = shared
19356 .resolve_environment_profile(&resolved)
19357 .expect_err("RO/FullAuto must refuse");
19358 assert_eq!(
19359 failure.code,
19360 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19361 );
19362 }
19363 }
19364
19365 #[test]
19369 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19370 let (shared, _defaults) = station_knobs_fixture();
19371 let allowlist =
19372 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19373 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19374 id: allowlist.clone(),
19375 permits: vec![
19376 crate::network_allowlist_catalog::NetworkPermitSketch {
19377 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19378 peer: "github.com:443".to_owned(),
19379 },
19380 crate::network_allowlist_catalog::NetworkPermitSketch {
19381 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19382 peer: "*.npmjs.org".to_owned(),
19383 },
19384 ],
19385 provider_native: None,
19386 };
19387 shared
19388 .network_allowlist_catalog
19389 .write()
19390 .unwrap_or_else(|poisoned| poisoned.into_inner())
19391 .insert(entry)
19392 .unwrap();
19393
19394 let unique = std::time::SystemTime::now()
19395 .duration_since(std::time::UNIX_EPOCH)
19396 .unwrap()
19397 .as_nanos();
19398 let provider_home = std::env::temp_dir().join(format!(
19399 "gate4agent-claude-provider-home-{}-{unique}",
19400 std::process::id(),
19401 ));
19402 std::fs::create_dir_all(&provider_home).unwrap();
19403
19404 let overlay = shared
19405 .resolve_network_allowlist_overlay_args(
19406 &agent("claude"),
19407 SessionMode::Pty,
19408 ApprovalLevel::Moderate,
19409 Some(&allowlist),
19410 Some(provider_home.as_path()),
19411 )
19412 .expect("Claude PTY settings overlay on unix");
19413 let settings_path = provider_home.join(
19414 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19415 );
19416 assert_eq!(
19417 overlay,
19418 vec![
19419 std::ffi::OsString::from("--settings"),
19420 std::ffi::OsString::from(settings_path.as_os_str()),
19421 ]
19422 );
19423 let body = std::fs::read_to_string(&settings_path).unwrap();
19424 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19425 assert_eq!(value["sandbox"]["enabled"], true);
19426 assert_eq!(
19427 value["sandbox"]["network"]["allowedDomains"],
19428 serde_json::json!(["github.com", "*.npmjs.org"])
19429 );
19430 assert!(settings_path.starts_with(&provider_home));
19431
19432 let acp = shared
19433 .resolve_network_allowlist_overlay_args(
19434 &agent("claude"),
19435 SessionMode::Acp,
19436 ApprovalLevel::Moderate,
19437 Some(&allowlist),
19438 Some(provider_home.as_path()),
19439 )
19440 .expect_err("Claude ACP must refuse settings overlay");
19441 assert_eq!(
19442 acp.code,
19443 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19444 );
19445
19446 let no_home = shared
19447 .resolve_network_allowlist_overlay_args(
19448 &agent("claude"),
19449 SessionMode::Pty,
19450 ApprovalLevel::Moderate,
19451 Some(&allowlist),
19452 None,
19453 )
19454 .expect_err("Claude overlay requires ProviderHome");
19455 assert_eq!(
19456 no_home.code,
19457 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19458 );
19459
19460 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19461 shared
19462 .network_allowlist_catalog
19463 .write()
19464 .unwrap_or_else(|poisoned| poisoned.into_inner())
19465 .insert_id(bare.clone())
19466 .unwrap();
19467 let empty = shared
19468 .resolve_network_allowlist_overlay_args(
19469 &agent("claude"),
19470 SessionMode::Pty,
19471 ApprovalLevel::Moderate,
19472 Some(&bare),
19473 Some(provider_home.as_path()),
19474 )
19475 .unwrap();
19476 assert!(empty.is_empty());
19477
19478 let _ = std::fs::remove_dir_all(&provider_home);
19479 }
19480
19481 #[test]
19483 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19484 let (shared, _defaults) = station_knobs_fixture();
19485 let allowlist =
19486 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19487 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19488 id: allowlist.clone(),
19489 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19490 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19491 peer: "example.com:443".to_owned(),
19492 }],
19493 provider_native: None,
19494 };
19495 shared
19496 .network_allowlist_catalog
19497 .write()
19498 .unwrap_or_else(|poisoned| poisoned.into_inner())
19499 .insert(entry)
19500 .unwrap();
19501 for provider in ["kimi", "grok"] {
19502 let overlay = shared
19503 .resolve_network_allowlist_overlay_args(
19504 &agent(provider),
19505 SessionMode::Pty,
19506 ApprovalLevel::Moderate,
19507 Some(&allowlist),
19508 None,
19509 )
19510 .unwrap();
19511 assert!(
19512 overlay.is_empty(),
19513 "{provider} must not invent network argv from permits"
19514 );
19515 }
19516 }
19517
19518 #[test]
19520 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19521 let unique = std::time::SystemTime::now()
19522 .duration_since(std::time::UNIX_EPOCH)
19523 .unwrap()
19524 .as_nanos();
19525 let path = std::env::temp_dir().join(format!(
19526 "gate4agent-node-allowlist-json-{}-{unique}.json",
19527 std::process::id(),
19528 ));
19529 std::fs::write(
19530 &path,
19531 r#"{
19532 "schema_version": 2,
19533 "entries": [
19534 {
19535 "id": "egress-default",
19536 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19537 },
19538 { "id": "lab-egress" }
19539 ]
19540 }"#,
19541 )
19542 .unwrap();
19543 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19544 .expect("json v2 catalog must load");
19545 let _ = std::fs::remove_file(&path);
19546 assert_eq!(loaded.len(), 2);
19547 let entry = loaded
19548 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19549 .unwrap();
19550 assert_eq!(entry.permits.len(), 1);
19551 let (shared, defaults) = station_knobs_fixture();
19552 {
19553 let mut catalog = shared
19554 .network_allowlist_catalog
19555 .write()
19556 .unwrap_or_else(|poisoned| poisoned.into_inner());
19557 *catalog = loaded;
19558 }
19559 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19560 let spec = crate::protocol::SpawnSpec {
19561 target: crate::protocol::SpawnTarget {
19562 node_id: shared.node_id.clone(),
19563 workspace_id: WorkspaceId::new("primary").unwrap(),
19564 worktree_id: None,
19565 },
19566 profile_id: defaults.profile_id.clone(),
19567 expected_profile_revision: defaults.revision.clone(),
19568 overrides: crate::protocol::SpawnOverrides {
19569 network_allowlist: Some(allowlist.clone()),
19570 ..crate::protocol::SpawnOverrides::default()
19571 },
19572 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19573 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19574 "station-knobs-catalog-json",
19575 )
19576 .unwrap(),
19577 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19578 };
19579 let resolved = spec.resolve(&defaults).unwrap();
19580 let receipt = shared
19581 .resolve_environment_profile(&resolved)
19582 .expect("permits-only entry ok for claude")
19583 .expect("env profile present");
19584 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19585 let inventory = shared
19586 .snapshot()
19587 .launch_inventory
19588 .expect("launch inventory present");
19589 let ids = inventory.network_allowlists.expect("ids surface");
19590 assert_eq!(
19591 ids,
19592 vec![
19593 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19594 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19595 ]
19596 );
19597 }
19598
19599
19600}
19601
19602
19603#[cfg(test)]
19604mod workspace_environment_collision_tests {
19605 use super::*;
19606 use crate::session_environment::{
19607 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19608 };
19609 use gate4agent_catalog::EnvMutation;
19610 use gate4agent_runtime_native::{
19611 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19612 NativeLaunchProfileId,
19613 };
19614 use std::ffi::OsString;
19615 use std::sync::Arc;
19616
19617 struct EmptyEnvironmentResolver;
19618
19619 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19620 fn resolve_child_environment(
19621 &self,
19622 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19623 Ok(vec![EnvMutation {
19624 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19625 value: None,
19626 }])
19627 }
19628 }
19629
19630 fn agent(value: &str) -> AgentId {
19631 AgentId::new(value).unwrap()
19632 }
19633
19634 fn claude_config_dir_collision_fixture() -> (
19636 NodeShared,
19637 ResolvedEnvironmentProfileReceipt,
19638 ResolvedEnvironmentProfileReceipt,
19639 ) {
19640 let catalog = active_registry().unwrap();
19641 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19642 let control = runtime.native_launch_profile_control();
19643 let workspace = WorkspaceConfig::new(
19644 WorkspaceId::new("primary").unwrap(),
19645 std::env::current_dir().unwrap(),
19646 )
19647 .unwrap();
19648 let shared = NodeShared::new_with_incarnation(
19649 handle,
19650 "fixture-token".to_owned(),
19651 NodeId::new("node-env-collision-test").unwrap(),
19652 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19653 vec![workspace],
19654 vec![agent("claude")],
19655 ProviderRuntimeStatuses::default(),
19656 None,
19657 Vec::new(),
19658 Vec::new(),
19659 SpawnProfileRegistry::default(),
19660 Some(control),
19661 None,
19662 None,
19663 None,
19664 Vec::new(),
19665 Vec::new(),
19666 Vec::new(),
19667 Vec::new(),
19668 None,
19669 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19670 );
19671
19672 let mut receipts = Vec::new();
19673 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19674 let profile_id =
19675 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19676 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19677 "claude-config-dir-{suffix}-r1"
19678 ))
19679 .unwrap();
19680 let materialization = NodeSessionMaterializationProfile::new(
19681 Vec::new(),
19682 vec![NodeSessionPathBinding::new(
19683 "CLAUDE_CONFIG_DIR",
19684 NodeSessionPathClass::ProviderHome,
19685 )
19686 .unwrap()],
19687 Vec::new(),
19688 )
19689 .unwrap();
19690 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19691 profile_id.clone(),
19692 profile_revision.clone(),
19693 agent("claude"),
19694 [NativeLaunchProfile::new(
19695 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19696 .unwrap(),
19697 agent("claude"),
19698 TransportKind::Pty,
19699 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19700 Arc::new(EmptyEnvironmentResolver),
19701 )
19702 .unwrap()],
19703 Some(materialization),
19704 )
19705 .unwrap();
19706 let (binding, native_profiles, materialization) = node_profile.into_parts();
19707 for native_profile in native_profiles {
19708 runtime.upsert_native_launch_profile(native_profile).unwrap();
19709 }
19710 shared
19711 .environment_profiles
19712 .write()
19713 .unwrap_or_else(|poisoned| poisoned.into_inner())
19714 .insert(binding.id.clone(), binding);
19715 shared
19716 .environment_materialization_profiles
19717 .write()
19718 .unwrap_or_else(|poisoned| poisoned.into_inner())
19719 .insert(profile_id.clone(), materialization.unwrap());
19720 receipts.push(ResolvedEnvironmentProfileReceipt {
19721 profile_id,
19722 profile_revision,
19723 network_allowlist: None,
19724 browser_profile_id: None,
19725 });
19726 }
19727 let profile_b = receipts.pop().unwrap();
19728 let profile_a = receipts.pop().unwrap();
19729 (shared, profile_a, profile_b)
19730 }
19731
19732 #[test]
19733 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19734 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19735 let primary = WorkspaceId::new("primary").unwrap();
19736 let worktree = WorkspaceId::new("worktree-b").unwrap();
19737 let address = SessionAddress {
19738 workspace_id: primary.clone(),
19739 session: SessionKey {
19740 instance_id: AgentInstanceId(41),
19741 generation: SessionGeneration(1),
19742 },
19743 };
19744 shared.bind_session_with_policy(
19745 &address,
19746 ProviderRuntimePolicy::raw_pty(),
19747 Some(profile_a.clone()),
19748 );
19749
19750 let refused = shared
19751 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19752 .unwrap_err();
19753 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19754
19755 let refused_none = shared
19757 .ensure_workspace_environment_profile_compatible(&primary, None)
19758 .unwrap_err();
19759 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19760
19761 shared
19763 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19764 .unwrap();
19765
19766 shared
19768 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19769 .unwrap();
19770 }
19771
19772 #[tokio::test]
19773 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19774 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19775 let primary = WorkspaceId::new("primary").unwrap();
19776 let address = SessionAddress {
19777 workspace_id: primary.clone(),
19778 session: SessionKey {
19779 instance_id: AgentInstanceId(41),
19780 generation: SessionGeneration(1),
19781 },
19782 };
19783 shared.bind_session_with_policy(
19784 &address,
19785 ProviderRuntimePolicy::raw_pty(),
19786 Some(profile_a),
19787 );
19788
19789 let failure = shared
19790 .spawn_session_with_deadline(
19791 primary,
19792 agent("claude"),
19793 SessionMode::Pty,
19794 gate4agent_types::TerminalSize {
19795 rows: 24,
19796 columns: 80,
19797 },
19798 None,
19799 Some(profile_b),
19800 None,
19801 None,
19802 None,
19803 None,
19804 SpawnRecordPolicy::ProviderIdentityOnly,
19805 Some(Instant::now() + Duration::from_secs(30)),
19806 &[],
19807 None,
19808 ApprovalLevel::default(),
19809 None,
19810 None,
19811 )
19812 .await
19813 .unwrap_err();
19814 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19815 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19817 assert!(shared.handle.snapshot().sessions.is_empty());
19818 }
19819}
19820
19821#[cfg(test)]
19822mod spawn_characterization_tests {
19823 use super::*;
19824 use crate::session_environment::{
19825 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19826 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19827 NodeSessionPathClass,
19828 };
19829 use gate4agent_catalog::EnvMutation;
19830 use gate4agent_runtime_native::{
19831 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19832 NativeLaunchProfile, NativeLaunchProfileId,
19833 };
19834 use std::ffi::OsString;
19835 use std::sync::Arc;
19836
19837 struct EmptyEnvironmentResolver;
19838
19839 struct FixtureSecretResolver {
19840 deny: Arc<AtomicBool>,
19841 }
19842
19843 impl NodeSecretResolver for FixtureSecretResolver {
19844 fn resolve(
19845 &self,
19846 _reference: &NodeSecretReference,
19847 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19848 if self.deny.load(Ordering::Acquire) {
19849 Err(NodeSecretResolveError::Denied)
19850 } else {
19851 NodeSecretValue::text("fixture-secret")
19852 .map_err(|_| NodeSecretResolveError::Unavailable)
19853 }
19854 }
19855 }
19856
19857 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19858 fn resolve_child_environment(
19859 &self,
19860 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19861 Ok(vec![EnvMutation {
19862 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19863 value: None,
19864 }])
19865 }
19866 }
19867
19868 fn agent(value: &str) -> AgentId {
19869 AgentId::new(value).unwrap()
19870 }
19871
19872 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19873 let unique = std::time::SystemTime::now()
19874 .duration_since(std::time::UNIX_EPOCH)
19875 .unwrap()
19876 .as_nanos();
19877 std::env::temp_dir().join(format!(
19878 "gate4agent-node-{label}-{}-{unique}",
19879 std::process::id(),
19880 ))
19881 }
19882
19883 fn materialization_test_shared(
19884 deny_secret: bool,
19885 ) -> (
19886 NodeShared,
19887 NativeRuntime,
19888 ResolvedEnvironmentProfileReceipt,
19889 PathBuf,
19890 Arc<AtomicBool>,
19891 ) {
19892 let workspace = WorkspaceConfig::new(
19893 WorkspaceId::new("primary").unwrap(),
19894 std::env::current_dir().unwrap(),
19895 )
19896 .unwrap();
19897 materialization_test_shared_with_workspace(deny_secret, workspace)
19898 }
19899
19900 fn materialization_test_shared_with_workspace(
19901 deny_secret: bool,
19902 workspace: WorkspaceConfig,
19903 ) -> (
19904 NodeShared,
19905 NativeRuntime,
19906 ResolvedEnvironmentProfileReceipt,
19907 PathBuf,
19908 Arc<AtomicBool>,
19909 ) {
19910 let root = temporary_workspace_root(if deny_secret {
19911 "materialization-denied"
19912 } else {
19913 "materialization-ready"
19914 });
19915 std::fs::create_dir_all(&root).unwrap();
19916 let materialization_root = root.join("session-environments");
19917 let deny = Arc::new(AtomicBool::new(deny_secret));
19918 let resolver = Arc::new(FixtureSecretResolver {
19919 deny: Arc::clone(&deny),
19920 });
19921 let materializer = SessionEnvironmentMaterializer::new(
19922 materialization_root,
19923 resolver,
19924 )
19925 .unwrap();
19926 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19927 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19928 "materialized-claude-r1",
19929 )
19930 .unwrap();
19931 let materialization = NodeSessionMaterializationProfile::new(
19932 vec![NodeSessionEnvironmentMutation::SetSecret {
19933 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19934 reference: NodeSecretReference::new("fixture-token").unwrap(),
19935 }],
19936 vec![NodeSessionPathBinding::new(
19937 "GATE4AGENT_PROVIDER_HOME",
19938 NodeSessionPathClass::ProviderHome,
19939 )
19940 .unwrap()],
19941 vec![NodeSessionFile::secret(
19942 NodeSessionPathClass::Config,
19943 "auth/token",
19944 NodeSecretReference::new("fixture-token").unwrap(),
19945 )
19946 .unwrap()],
19947 )
19948 .unwrap();
19949 let catalog = active_registry().unwrap();
19950 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19951 let control = runtime.native_launch_profile_control();
19952 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19953 profile_id.clone(),
19954 profile_revision.clone(),
19955 agent("claude"),
19956 [NativeLaunchProfile::new(
19957 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19958 agent("claude"),
19959 TransportKind::Pty,
19960 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19961 Arc::new(EmptyEnvironmentResolver),
19962 )
19963 .unwrap()],
19964 Some(materialization),
19965 )
19966 .unwrap();
19967 let (binding, native_profiles, materialization) = node_profile.into_parts();
19968 for native_profile in native_profiles {
19969 runtime.upsert_native_launch_profile(native_profile).unwrap();
19970 }
19971 let shared = NodeShared::new_with_incarnation(
19972 handle,
19973 "fixture-token".to_owned(),
19974 NodeId::new("node-materialization-test").unwrap(),
19975 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19976 vec![workspace],
19977 vec![agent("claude")],
19978 ProviderRuntimeStatuses::default(),
19979 None,
19980 Vec::new(),
19981 Vec::new(),
19982 SpawnProfileRegistry::default(),
19983 Some(control),
19984 None,
19985 Some(materializer),
19986 None,
19987 Vec::new(),
19988 Vec::new(),
19989 Vec::new(),
19990 Vec::new(),
19991 None,
19992 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19993 );
19994 shared
19995 .environment_profiles
19996 .write()
19997 .unwrap_or_else(|poisoned| poisoned.into_inner())
19998 .insert(binding.id.clone(), binding);
19999 shared
20000 .environment_materialization_profiles
20001 .write()
20002 .unwrap_or_else(|poisoned| poisoned.into_inner())
20003 .insert(profile_id.clone(), materialization.unwrap());
20004 (
20005 shared,
20006 runtime,
20007 ResolvedEnvironmentProfileReceipt {
20008 profile_id,
20009 profile_revision,
20010 network_allowlist: None,
20011 browser_profile_id: None,
20012 },
20013 root,
20014 deny,
20015 )
20016 }
20017
20018
20019 async fn characterization_spawn(
20022 shared: &NodeShared,
20023 mode: SessionMode,
20024 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20025 required: &[ProviderRuntimeCapability],
20026 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20027 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20028 shared
20029 .spawn_session_with_deadline(
20030 WorkspaceId::new("primary").unwrap(),
20031 agent("claude"),
20032 mode,
20033 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20034 None,
20035 environment_profile,
20036 None,
20037 None,
20038 None,
20039 Some(ProviderRuntimePolicy::raw_pty()),
20040 SpawnRecordPolicy::ProviderIdentityOnly,
20041 Some(Instant::now() + Duration::from_secs(30)),
20042 required,
20043 harness_mcp,
20044 ApprovalLevel::default(),
20045 None,
20046 None,
20047 )
20048 .await
20049 }
20050
20051 fn assert_no_session_state(shared: &NodeShared) {
20052 assert!(shared.handle.snapshot().sessions.is_empty());
20053 assert!(shared
20054 .session_bindings
20055 .lock()
20056 .unwrap_or_else(|poisoned| poisoned.into_inner())
20057 .is_empty());
20058 assert!(shared
20059 .materializations
20060 .lock()
20061 .unwrap_or_else(|poisoned| poisoned.into_inner())
20062 .is_empty());
20063 }
20064
20065 #[tokio::test]
20066 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20067 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20068 let error = characterization_spawn(
20069 &shared,
20070 SessionMode::Pty,
20071 None,
20072 &[ProviderRuntimeCapability::SemanticReadiness],
20073 None,
20074 )
20075 .await
20076 .unwrap_err();
20077 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20078 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20079 assert_no_session_state(&shared);
20080 drop(shared);
20081 drop(runtime);
20082 std::fs::remove_dir_all(root).unwrap();
20083 }
20084
20085 #[tokio::test]
20086 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20087 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20088 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20089 .await
20090 .unwrap_err();
20091 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20092 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20093 assert_no_session_state(&shared);
20094 drop(shared);
20095 drop(runtime);
20096 std::fs::remove_dir_all(root).unwrap();
20097 }
20098
20099 #[test]
20100 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20101 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20102 let address = SessionAddress {
20103 workspace_id: WorkspaceId::new("primary").unwrap(),
20104 session: SessionKey {
20105 instance_id: AgentInstanceId(77),
20106 generation: SessionGeneration(1),
20107 },
20108 };
20109 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20110 let removed = shared.remove_binding(&address).expect("binding removed");
20111 assert!(removed.environment_profile.is_some());
20112 assert!(shared.remove_binding(&address).is_none());
20113 assert_no_session_state(&shared);
20114 drop(shared);
20115 drop(runtime);
20116 std::fs::remove_dir_all(root).unwrap();
20117 }
20118
20119 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20120 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20121 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20122 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20123 .expect("review current test executable as fixture helper");
20124 let registry = HarnessMcpProxyRegistry::new(helper);
20125 let spec = crate::protocol::SpawnSpec {
20126 target: crate::protocol::SpawnTarget {
20127 node_id: NodeId::new("node-characterization").unwrap(),
20128 workspace_id: WorkspaceId::new("primary").unwrap(),
20129 worktree_id: None,
20130 },
20131 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20132 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20133 overrides: crate::protocol::SpawnOverrides::default(),
20134 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20135 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20136 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20137 };
20138 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20139 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20140 let launch = HarnessMcpLaunchV1 {
20141 server_name: "fixture-harness".to_owned(),
20142 args: vec!["--session-proxy".to_owned()],
20143 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20144 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20145 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20146 trace: None,
20147 scrub_env: Vec::new(),
20148 };
20149 let expires = unix_time_ms().saturating_add(30_000);
20150 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20151 registry
20152 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20153 .unwrap()
20154 }
20155
20156 #[tokio::test]
20157 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20158 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20159 let prepared = characterization_prepared_harness("claude").await;
20160 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20161 .await
20162 .unwrap_err();
20163 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20165 let mismatched = characterization_prepared_harness("codex").await;
20166 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20167 .await
20168 .unwrap_err();
20169 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20170 assert_no_session_state(&shared);
20171 drop(shared);
20172 drop(runtime);
20173 std::fs::remove_dir_all(root).unwrap();
20174 }
20175
20176 #[tokio::test]
20177 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20178 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20179 shared.native_launch_profile_control = None;
20180 let prepared = characterization_prepared_harness("claude").await;
20181 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20182 .await
20183 .unwrap_err();
20184 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20185 assert_no_session_state(&shared);
20186 drop(shared);
20187 drop(runtime);
20188 std::fs::remove_dir_all(root).unwrap();
20189 }
20190}
20191
20192#[cfg(all(test, windows))]
20193mod tests {
20194 use super::*;
20195 use gate4agent_types::ProviderActivity;
20196 use crate::session_environment::{
20197 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20198 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20199 NodeSessionPathClass,
20200 };
20201 use gate4agent_catalog::EnvMutation;
20202 use gate4agent_runtime_native::{
20203 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20204 NativeLaunchProfile, NativeLaunchProfileId,
20205 };
20206 use std::ffi::OsString;
20207 use std::sync::Arc;
20208
20209 struct EmptyEnvironmentResolver;
20210
20211 struct FixtureSecretResolver {
20212 deny: Arc<AtomicBool>,
20213 }
20214
20215 impl NodeSecretResolver for FixtureSecretResolver {
20216 fn resolve(
20217 &self,
20218 _reference: &NodeSecretReference,
20219 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20220 if self.deny.load(Ordering::Acquire) {
20221 Err(NodeSecretResolveError::Denied)
20222 } else {
20223 NodeSecretValue::text("fixture-secret")
20224 .map_err(|_| NodeSecretResolveError::Unavailable)
20225 }
20226 }
20227 }
20228
20229 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20230 fn resolve_child_environment(
20231 &self,
20232 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20233 Ok(vec![EnvMutation {
20234 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20235 value: None,
20236 }])
20237 }
20238 }
20239
20240 fn agent(value: &str) -> AgentId {
20241 AgentId::new(value).unwrap()
20242 }
20243
20244 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20245 let catalog = active_registry().unwrap();
20246 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20247 let workspace = WorkspaceConfig::new(
20248 WorkspaceId::new("primary").unwrap(),
20249 std::env::current_dir().unwrap(),
20250 )
20251 .unwrap()
20252 .with_worktree_service_mode(mode);
20253 NodeShared::new(
20254 handle,
20255 "fixture-token".to_owned(),
20256 NodeId::new("node-terminal-test").unwrap(),
20257 vec![workspace],
20258 vec![agent("claude")],
20259 )
20260 }
20261
20262 fn terminal_test_shared() -> NodeShared {
20263 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20264 }
20265
20266 #[cfg(feature = "fixture")]
20267 #[test]
20268 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20269 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20270 assert_eq!(probe.latest_code(), None);
20271
20272 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20273 assert_eq!(
20274 probe.latest_code(),
20275 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20276 );
20277
20278 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20279 assert_eq!(
20280 probe.latest_code(),
20281 Some(NodeFailureCode::SpawnDeadlineExceeded),
20282 );
20283
20284 probe.record(None);
20285 assert_eq!(probe.latest_code(), None);
20286 }
20287
20288 #[test]
20289 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20290 let source = temporary_workspace_root("launch-inventory-source");
20291 let allocation = temporary_workspace_root("launch-inventory-allocation");
20292 std::fs::create_dir_all(&source).unwrap();
20293 std::fs::create_dir_all(&allocation).unwrap();
20294 let workspace_id = WorkspaceId::new("primary").unwrap();
20295 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20296 .unwrap()
20297 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20298 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20299 workspace = workspace
20300 .with_managed_worktree_profile(
20301 ManagedWorktreeProfile::new(
20302 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20303 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20304 &allocation,
20305 "gate4agent",
20306 "HEAD",
20307 ManagedWorktreeRetention::RemoveWhenReleased,
20308 )
20309 .unwrap(),
20310 )
20311 .unwrap();
20312 }
20313 let overflow = workspace.clone().with_managed_worktree_profile(
20314 ManagedWorktreeProfile::new(
20315 WorktreeProfileId::new("overflow").unwrap(),
20316 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20317 &allocation,
20318 "gate4agent",
20319 "HEAD",
20320 ManagedWorktreeRetention::Retain,
20321 )
20322 .unwrap(),
20323 );
20324 assert!(matches!(
20325 overflow,
20326 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20327 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20328 ..
20329 }),
20330 ));
20331
20332 let catalog = active_registry().unwrap();
20333 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20334 let shared = NodeShared::new(
20335 handle,
20336 "fixture-token".to_owned(),
20337 NodeId::new("node-launch-inventory").unwrap(),
20338 vec![workspace],
20339 vec![agent("claude")],
20340 );
20341 let snapshot = shared.snapshot();
20342 let launch = snapshot.launch_inventory.as_ref().unwrap();
20343 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20344 assert!(launch.bundles.as_ref().unwrap().is_empty());
20345 let profiles = snapshot.workspaces[0]
20346 .managed_worktree_profiles
20347 .as_ref()
20348 .unwrap();
20349 assert_eq!(
20350 snapshot.workspaces[0].worktree_service_mode,
20351 Some(WorktreeServiceMode::Managed),
20352 );
20353 assert_eq!(
20354 profiles.profiles.len(),
20355 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20356 );
20357 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20358 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20359 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20360 }
20361 let legacy = snapshot_for_wire(
20362 &shared,
20363 true,
20364 true,
20365 false,
20366 false,
20367 true,
20368 false,
20369 true,
20370 false,
20371 true,
20372 );
20373 assert!(legacy.launch_inventory.is_none());
20374 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20375
20376 drop(shared);
20377 std::fs::remove_dir_all(source).unwrap();
20378 std::fs::remove_dir_all(allocation).unwrap();
20379 }
20380
20381 #[test]
20382 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20383 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20384 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20385 }));
20386 let source = gate4agent_types::ProviderSource {
20387 family: AdapterFamily::PtySemantic,
20388 binding: AdapterBinding::new(
20389 gate4agent_types::AdapterId::new("claude").unwrap(),
20390 "fixture/v1",
20391 gate4agent_types::AdapterVerification::SyntheticFixture,
20392 )
20393 .unwrap(),
20394 };
20395 let provider = ProviderSnapshot {
20396 sequence: 17,
20397 session: Some(ProviderSessionIdentity {
20398 key: ProviderSessionKey::SessionId,
20399 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20400 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20401 }),
20402 model: Some("PRIVATE_MODEL".to_owned()),
20403 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20404 completed_turns: 3,
20405 usage: gate4agent_types::TokenUsage {
20406 input_tokens: 10,
20407 output_tokens: 20,
20408 cache_read_tokens: 30,
20409 cache_write_tokens: 40,
20410 reasoning_tokens: 50,
20411 context_window: Some(200_000),
20412 },
20413 lead_activity: ProviderActivity::WaitingForInput,
20414 activity: ProviderActivity::WaitingForInput,
20415 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20416 active_tools: (0..9)
20417 .map(|index| gate4agent_types::ActiveProviderTool {
20418 id: format!("private-tool-id-{index}"),
20419 name: if index == 0 {
20420 format!(" Read\u{0000}{}", "x".repeat(80))
20421 } else {
20422 format!("Tool-{index}")
20423 },
20424 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20425 })
20426 .collect(),
20427 interactions: vec![gate4agent_types::ProviderInteraction {
20428 id: gate4agent_types::ProviderInteractionId(17),
20429 source: source.clone(),
20430 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20431 interaction_kind: ProviderInteractionKind::Approval,
20432 tool_name: " Approve\u{0007}Tool ".to_owned(),
20433 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20434 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20435 resume_lead_activity: Some(ProviderActivity::Working),
20436 status: ProviderInteractionStatus::Pending,
20437 }],
20438 subagents: vec![gate4agent_types::ProviderSubagent {
20439 source: source.clone(),
20440 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20441 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20442 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20443 }],
20444 sources: vec![gate4agent_types::ProviderSourceCursor {
20445 source,
20446 sequence: 17,
20447 gap_count: 2,
20448 stale: true,
20449 }],
20450 last_event: Some(ProviderEvent::Error {
20451 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20452 }),
20453 gap_count: 2,
20454 stale: true,
20455 };
20456 let address = terminal_address(3);
20457 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20458 assert_eq!(entry.address, address);
20459 assert_eq!(entry.progress.provider_sequence, 17);
20460 assert_eq!(entry.progress.active_tool_count, 9);
20461 assert_eq!(
20462 entry.progress.active_tool_labels.len(),
20463 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20464 );
20465 assert_eq!(entry.progress.subagent_count, 1);
20466 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20467 assert!(entry.progress.truncated);
20468 let encoded = serde_json::to_string(&entry).unwrap();
20469 for forbidden in [
20470 "PRIVATE_PROVIDER_SESSION",
20471 "PRIVATE_PROVIDER_PATH",
20472 "PRIVATE_MODEL",
20473 "PRIVATE_TOOL_INVENTORY",
20474 "PRIVATE_CURRENT_PROMPT",
20475 "PRIVATE_TOOL_INPUT_JSON",
20476 "PRIVATE_PROVIDER_REQUEST",
20477 "PRIVATE_INTERACTION_PROMPT",
20478 "PRIVATE_INTERACTION_AGENT",
20479 "PRIVATE_SUBAGENT_ID",
20480 "PRIVATE_SUBAGENT_TYPE",
20481 "PRIVATE_SUBAGENT_DESCRIPTION",
20482 "PRIVATE_PROVIDER_ERROR",
20483 "input_json",
20484 "current_prompt",
20485 "session_id",
20486 ] {
20487 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20488 }
20489
20490 let snapshot = NodeSnapshot {
20491 node_id: NodeId::new("node-progress-test").unwrap(),
20492 enabled_providers: vec![agent("claude")],
20493 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20494 workspaces: Vec::new(),
20495 session_records: Vec::new(),
20496 managed_worktrees: Vec::new(),
20497 launch_inventory: None,
20498 agent_progress: vec![entry],
20499 };
20500 let mut legacy_reply = ResponseEnvelope {
20501 request_id: 1,
20502 result: Ok(NodeResponse::Snapshot {
20503 event_sequence: 0,
20504 controller: None,
20505 snapshot: snapshot.clone(),
20506 }),
20507 };
20508 project_response_without_agent_progress(&mut legacy_reply);
20509 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20510 panic!("agent progress projection changed response kind");
20511 };
20512 assert!(legacy.agent_progress.is_empty());
20513 assert_eq!(snapshot.agent_progress.len(), 1);
20514 }
20515
20516 fn terminal_address(generation: u64) -> SessionAddress {
20517 SessionAddress {
20518 workspace_id: WorkspaceId::new("primary").unwrap(),
20519 session: SessionKey {
20520 instance_id: AgentInstanceId(41),
20521 generation: SessionGeneration(generation),
20522 },
20523 }
20524 }
20525
20526 fn spawn_spec_fixture() -> SpawnSpec {
20527 SpawnSpec {
20528 target: crate::protocol::SpawnTarget {
20529 node_id: NodeId::new("node-terminal-test").unwrap(),
20530 workspace_id: WorkspaceId::new("primary").unwrap(),
20531 worktree_id: None,
20532 },
20533 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20534 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20535 .unwrap(),
20536 overrides: crate::protocol::SpawnOverrides::default(),
20537 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20538 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20539 required_capabilities: SpawnRequiredCapabilities::default(),
20540 }
20541 }
20542
20543 fn environment_profile_test_shared(
20544 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20545 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20546 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20547 "local-claude-r1",
20548 )
20549 .unwrap();
20550 let spawn_profiles = SpawnProfileRegistry::new([
20551 crate::protocol::SpawnProfileDefaults {
20552 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20553 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20554 provider: agent("claude"),
20555 mode: SessionMode::Pty,
20556 terminal_size: gate4agent_types::TerminalSize {
20557 rows: 24,
20558 columns: 80,
20559 },
20560 prompt: None,
20561 bundle_id: None,
20562 context_id: None,
20563 environment_profile_id: Some(profile_id.clone()),
20564 },
20565 ])
20566 .unwrap();
20567 let catalog = active_registry().unwrap();
20568 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20569 let control = runtime.native_launch_profile_control();
20570 let node_profile = NodeEnvironmentProfile::new(
20571 profile_id.clone(),
20572 profile_revision.clone(),
20573 agent("claude"),
20574 [NativeLaunchProfile::new(
20575 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20576 agent("claude"),
20577 TransportKind::Pty,
20578 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20579 Arc::new(EmptyEnvironmentResolver),
20580 )
20581 .unwrap()],
20582 )
20583 .unwrap();
20584 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20585 for native_profile in native_profiles {
20586 runtime.upsert_native_launch_profile(native_profile).unwrap();
20587 }
20588 let workspace = WorkspaceConfig::new(
20589 WorkspaceId::new("primary").unwrap(),
20590 std::env::current_dir().unwrap(),
20591 )
20592 .unwrap();
20593 let shared = NodeShared::new_with_incarnation(
20594 handle,
20595 "fixture-token".to_owned(),
20596 NodeId::new("node-terminal-test").unwrap(),
20597 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20598 vec![workspace],
20599 vec![agent("claude")],
20600 ProviderRuntimeStatuses::default(),
20601 None,
20602 Vec::new(),
20603 Vec::new(),
20604 spawn_profiles,
20605 Some(control),
20606 None,
20607 None,
20608 None,
20609 Vec::new(),
20610 Vec::new(),
20611 Vec::new(),
20612 Vec::new(),
20613 None,
20614 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20615 );
20616 shared
20617 .environment_profiles
20618 .write()
20619 .unwrap_or_else(|poisoned| poisoned.into_inner())
20620 .insert(binding.id.clone(), binding);
20621 (
20622 shared,
20623 runtime,
20624 ResolvedEnvironmentProfileReceipt {
20625 profile_id,
20626 profile_revision,
20627 network_allowlist: None,
20628 browser_profile_id: None,
20629 },
20630 )
20631 }
20632
20633 fn materialization_test_shared(
20634 deny_secret: bool,
20635 ) -> (
20636 NodeShared,
20637 NativeRuntime,
20638 ResolvedEnvironmentProfileReceipt,
20639 PathBuf,
20640 Arc<AtomicBool>,
20641 ) {
20642 let workspace = WorkspaceConfig::new(
20643 WorkspaceId::new("primary").unwrap(),
20644 std::env::current_dir().unwrap(),
20645 )
20646 .unwrap();
20647 materialization_test_shared_with_workspace(deny_secret, workspace)
20648 }
20649
20650 fn materialization_test_shared_with_workspace(
20651 deny_secret: bool,
20652 workspace: WorkspaceConfig,
20653 ) -> (
20654 NodeShared,
20655 NativeRuntime,
20656 ResolvedEnvironmentProfileReceipt,
20657 PathBuf,
20658 Arc<AtomicBool>,
20659 ) {
20660 let root = temporary_workspace_root(if deny_secret {
20661 "materialization-denied"
20662 } else {
20663 "materialization-ready"
20664 });
20665 std::fs::create_dir_all(&root).unwrap();
20666 let materialization_root = root.join("session-environments");
20667 let deny = Arc::new(AtomicBool::new(deny_secret));
20668 let resolver = Arc::new(FixtureSecretResolver {
20669 deny: Arc::clone(&deny),
20670 });
20671 let materializer = SessionEnvironmentMaterializer::new(
20672 materialization_root,
20673 resolver,
20674 )
20675 .unwrap();
20676 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20677 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20678 "materialized-claude-r1",
20679 )
20680 .unwrap();
20681 let materialization = NodeSessionMaterializationProfile::new(
20682 vec![NodeSessionEnvironmentMutation::SetSecret {
20683 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20684 reference: NodeSecretReference::new("fixture-token").unwrap(),
20685 }],
20686 vec![NodeSessionPathBinding::new(
20687 "GATE4AGENT_PROVIDER_HOME",
20688 NodeSessionPathClass::ProviderHome,
20689 )
20690 .unwrap()],
20691 vec![NodeSessionFile::secret(
20692 NodeSessionPathClass::Config,
20693 "auth/token",
20694 NodeSecretReference::new("fixture-token").unwrap(),
20695 )
20696 .unwrap()],
20697 )
20698 .unwrap();
20699 let catalog = active_registry().unwrap();
20700 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20701 let control = runtime.native_launch_profile_control();
20702 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20703 profile_id.clone(),
20704 profile_revision.clone(),
20705 agent("claude"),
20706 [NativeLaunchProfile::new(
20707 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20708 agent("claude"),
20709 TransportKind::Pty,
20710 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20711 Arc::new(EmptyEnvironmentResolver),
20712 )
20713 .unwrap()],
20714 Some(materialization),
20715 )
20716 .unwrap();
20717 let (binding, native_profiles, materialization) = node_profile.into_parts();
20718 for native_profile in native_profiles {
20719 runtime.upsert_native_launch_profile(native_profile).unwrap();
20720 }
20721 let shared = NodeShared::new_with_incarnation(
20722 handle,
20723 "fixture-token".to_owned(),
20724 NodeId::new("node-materialization-test").unwrap(),
20725 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20726 vec![workspace],
20727 vec![agent("claude")],
20728 ProviderRuntimeStatuses::default(),
20729 None,
20730 Vec::new(),
20731 Vec::new(),
20732 SpawnProfileRegistry::default(),
20733 Some(control),
20734 None,
20735 Some(materializer),
20736 None,
20737 Vec::new(),
20738 Vec::new(),
20739 Vec::new(),
20740 Vec::new(),
20741 None,
20742 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20743 );
20744 shared
20745 .environment_profiles
20746 .write()
20747 .unwrap_or_else(|poisoned| poisoned.into_inner())
20748 .insert(binding.id.clone(), binding);
20749 shared
20750 .environment_materialization_profiles
20751 .write()
20752 .unwrap_or_else(|poisoned| poisoned.into_inner())
20753 .insert(profile_id.clone(), materialization.unwrap());
20754 (
20755 shared,
20756 runtime,
20757 ResolvedEnvironmentProfileReceipt {
20758 profile_id,
20759 profile_revision,
20760 network_allowlist: None,
20761 browser_profile_id: None,
20762 },
20763 root,
20764 deny,
20765 )
20766 }
20767
20768 #[cfg(feature = "fixture")]
20769 #[derive(Clone, Copy)]
20770 enum CodexHomeProfile {
20771 Missing,
20772 WrongClass,
20773 Exact,
20774 }
20775
20776 #[cfg(feature = "fixture")]
20777 fn codex_bundle_test_shared(
20778 home_profile: CodexHomeProfile,
20779 ) -> (
20780 NodeShared,
20781 NativeRuntime,
20782 ResolvedEnvironmentProfileReceipt,
20783 NodeBundle,
20784 PathBuf,
20785 ) {
20786 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20787 const CLAUDE_MANIFEST: &[u8] =
20788 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20789 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20790 const DIGEST: &str =
20791 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20792
20793 let (shared, mut runtime, _claude_receipt, root, _deny) =
20794 materialization_test_shared(false);
20795 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20796 CodexHomeProfile::Missing => "codex-home-missing",
20797 CodexHomeProfile::WrongClass => "codex-home-wrong",
20798 CodexHomeProfile::Exact => "codex-home-exact",
20799 })
20800 .unwrap();
20801 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20802 "codex-home-r1",
20803 )
20804 .unwrap();
20805 let materialization = match home_profile {
20806 CodexHomeProfile::Missing => None,
20807 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20808 Some(NodeSessionMaterializationProfile::new(
20809 Vec::new(),
20810 vec![NodeSessionPathBinding::new(
20811 "CODEX_HOME",
20812 if matches!(home_profile, CodexHomeProfile::Exact) {
20813 NodeSessionPathClass::ProviderHome
20814 } else {
20815 NodeSessionPathClass::Config
20816 },
20817 )
20818 .unwrap()],
20819 Vec::new(),
20820 )
20821 .unwrap())
20822 }
20823 };
20824 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20825 profile_id.clone(),
20826 profile_revision.clone(),
20827 agent("codex"),
20828 [NativeLaunchProfile::new(
20829 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20830 agent("codex"),
20831 TransportKind::Pty,
20832 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20833 Arc::new(EmptyEnvironmentResolver),
20834 )
20835 .unwrap()],
20836 materialization,
20837 )
20838 .unwrap();
20839 let (binding, native_profiles, materialization) = node_profile.into_parts();
20840 for native_profile in native_profiles {
20841 runtime.upsert_native_launch_profile(native_profile).unwrap();
20842 }
20843 shared
20844 .environment_profiles
20845 .write()
20846 .unwrap_or_else(|poisoned| poisoned.into_inner())
20847 .insert(binding.id.clone(), binding);
20848 if let Some(materialization) = materialization {
20849 shared
20850 .environment_materialization_profiles
20851 .write()
20852 .unwrap_or_else(|poisoned| poisoned.into_inner())
20853 .insert(profile_id.clone(), materialization);
20854 }
20855
20856 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20857 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20858 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20859 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20860 std::fs::write(
20861 bundle_source.join(".claude-plugin/plugin.json"),
20862 CLAUDE_MANIFEST,
20863 )
20864 .unwrap();
20865 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20866 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20867 let bundle = NodeBundle::new(
20868 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20869 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20870 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20871 &bundle_source,
20872 )
20873 .unwrap();
20874 *shared
20875 .bundle_catalog
20876 .write()
20877 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20878 BundleCatalog::new([bundle.clone()]).unwrap();
20879 (
20880 shared,
20881 runtime,
20882 ResolvedEnvironmentProfileReceipt {
20883 profile_id,
20884 profile_revision,
20885 network_allowlist: None,
20886 browser_profile_id: None,
20887 },
20888 bundle,
20889 root,
20890 )
20891 }
20892
20893 #[cfg(feature = "fixture")]
20894 #[test]
20895 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20896 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20897 let (shared, runtime, receipt, bundle, root) =
20898 codex_bundle_test_shared(home_profile);
20899 let error = shared
20900 .bundle_layout(
20901 &agent("codex"),
20902 SessionMode::Pty,
20903 Some(&receipt),
20904 &bundle,
20905 )
20906 .unwrap_err();
20907 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20908 assert!(shared
20909 .materializations
20910 .lock()
20911 .unwrap_or_else(|poisoned| poisoned.into_inner())
20912 .is_empty());
20913 let materialization_root = root.join("session-environments");
20914 assert!(!std::fs::read_dir(&materialization_root)
20915 .unwrap()
20916 .filter_map(Result::ok)
20917 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20918 drop(shared);
20919 drop(runtime);
20920 let _ = std::fs::remove_dir_all(root);
20921 }
20922 }
20923
20924 #[cfg(feature = "fixture")]
20925 #[test]
20926 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20927 let (shared, runtime, receipt, bundle, root) =
20928 codex_bundle_test_shared(CodexHomeProfile::Exact);
20929 let address = SessionAddress {
20930 workspace_id: WorkspaceId::new("primary").unwrap(),
20931 session: SessionKey {
20932 instance_id: AgentInstanceId(310),
20933 generation: SessionGeneration(1),
20934 },
20935 };
20936 let (overlay, guard) = shared
20937 .prepare_session_materialization(
20938 &address,
20939 &agent("codex"),
20940 SessionMode::Pty,
20941 Some(&receipt),
20942 Some(&bundle.receipt()),
20943 None,
20944 None,
20945 )
20946 .unwrap()
20947 .unwrap();
20948 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20949 let ownership = shared
20950 .materializations
20951 .lock()
20952 .unwrap_or_else(|poisoned| poisoned.into_inner())
20953 .get(guard.id().unwrap())
20954 .unwrap()
20955 .clone();
20956 let (profile, _, _) = shared
20957 .materialization_profile(
20958 &agent("codex"),
20959 SessionMode::Pty,
20960 Some(&receipt),
20961 Some(&bundle.receipt()),
20962 None,
20963 )
20964 .unwrap()
20965 .unwrap();
20966 let environment = shared
20967 .session_environment_materializer
20968 .as_ref()
20969 .unwrap()
20970 .resolve_environment(&ownership, &profile)
20971 .unwrap();
20972 assert!(environment.iter().any(|mutation| {
20973 mutation.key == OsString::from("CODEX_HOME")
20974 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20975 }));
20976 assert_eq!(
20977 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20978 .unwrap(),
20979 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20980 );
20981 assert!(std::fs::read_dir(ownership.bundle_root())
20982 .unwrap()
20983 .next()
20984 .is_none());
20985 drop(guard);
20986 drop(shared);
20987 drop(runtime);
20988 let _ = std::fs::remove_dir_all(root);
20989 }
20990
20991 #[cfg(feature = "fixture")]
20992 #[test]
20993 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20994 let (shared, runtime, receipt, bundle, root) =
20995 codex_bundle_test_shared(CodexHomeProfile::Exact);
20996 let bundle_receipt = bundle.receipt();
20997 let address = SessionAddress {
20998 workspace_id: WorkspaceId::new("primary").unwrap(),
20999 session: SessionKey {
21000 instance_id: AgentInstanceId(311),
21001 generation: SessionGeneration(1),
21002 },
21003 };
21004 let (_overlay, guard) = shared
21005 .prepare_session_materialization(
21006 &address,
21007 &agent("codex"),
21008 SessionMode::Pty,
21009 Some(&receipt),
21010 Some(&bundle_receipt),
21011 None,
21012 None,
21013 )
21014 .unwrap()
21015 .unwrap();
21016 let materialization_id = guard.id().unwrap().clone();
21017 let skill_path = shared
21018 .materializations
21019 .lock()
21020 .unwrap_or_else(|poisoned| poisoned.into_inner())
21021 .get(&materialization_id)
21022 .unwrap()
21023 .provider_home()
21024 .join("skills/review-code/SKILL.md");
21025 let record_id = shared
21026 .bind_spawn_session_with_materialization(
21027 &address,
21028 agent("codex"),
21029 SessionMode::Pty,
21030 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21031 SpawnRecordPolicy::ProviderIdentityOnly,
21032 Some(receipt.clone()),
21033 Some(bundle_receipt.clone()),
21034 None,
21035 Some(materialization_id.clone()),
21036 )
21037 .unwrap()
21038 .unwrap();
21039 guard.retain();
21040 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21041
21042 let error = match shared.resolve_record_materialization(
21043 &record_id,
21044 &agent("codex"),
21045 SessionMode::Pty,
21046 Some(&receipt),
21047 Some(&bundle_receipt),
21048 None,
21049 ) {
21050 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21051 Err(error) => error,
21052 };
21053 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21054 assert_eq!(
21055 shared
21056 .materializations
21057 .lock()
21058 .unwrap_or_else(|poisoned| poisoned.into_inner())
21059 .get(&materialization_id)
21060 .unwrap()
21061 .state(),
21062 MaterializationState::RecoveryRequired,
21063 );
21064 drop(shared);
21065 drop(runtime);
21066 let _ = std::fs::remove_dir_all(root);
21067 }
21068
21069 fn bind_record_owned_materialization_fixture(
21070 shared: &NodeShared,
21071 receipt: &ResolvedEnvironmentProfileReceipt,
21072 instance_id: u64,
21073 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21074 let address = SessionAddress {
21075 workspace_id: WorkspaceId::new("primary").unwrap(),
21076 session: SessionKey {
21077 instance_id: AgentInstanceId(instance_id),
21078 generation: SessionGeneration(1),
21079 },
21080 };
21081 let (overlay, materialization_guard) = shared
21082 .prepare_session_materialization(
21083 &address,
21084 &agent("claude"),
21085 SessionMode::Pty,
21086 Some(receipt),
21087 None,
21088 None,
21089 None,
21090 )
21091 .unwrap()
21092 .unwrap();
21093 let materialization_id = materialization_guard.id().unwrap().clone();
21094 let materialization_root = shared
21095 .materializations
21096 .lock()
21097 .unwrap_or_else(|poisoned| poisoned.into_inner())
21098 .get(&materialization_id)
21099 .unwrap()
21100 .root()
21101 .to_path_buf();
21102 let selection = shared
21103 .select_environment_profile(
21104 address.session.instance_id,
21105 &agent("claude"),
21106 SessionMode::Pty,
21107 Some(receipt),
21108 )
21109 .unwrap()
21110 .unwrap();
21111 assert!(shared
21112 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21113 .unwrap()
21114 .is_none());
21115 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21116 let record_id = shared
21117 .bind_spawn_session_with_materialization(
21118 &address,
21119 agent("claude"),
21120 SessionMode::Pty,
21121 policy,
21122 SpawnRecordPolicy::ProviderIdentityOnly,
21123 Some(receipt.clone()),
21124 None,
21125 None,
21126 Some(materialization_id.clone()),
21127 )
21128 .unwrap()
21129 .unwrap();
21130 selection.retain();
21131 materialization_guard.retain();
21132 (address, record_id, materialization_id, materialization_root)
21133 }
21134
21135 #[test]
21136 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21137 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21138 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21139 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21140 }));
21141 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21142 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21143 let mut spec = spawn_spec_fixture();
21144 spec.expected_profile_revision =
21145 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21146 assert!(matches!(
21147 &spec.overrides.environment_profile_id,
21148 crate::protocol::SpawnOverride::Inherit,
21149 ));
21150 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21151 .requires_child_environment_profile_capability());
21152 assert!(request_requires_child_environment_profile(
21153 &shared,
21154 &NodeRequest::SpawnSpec { spec: spec.clone() },
21155 ));
21156
21157 let address = terminal_address(1);
21158 shared.bind_session_with_policy(
21159 &address,
21160 ProviderRuntimePolicy::raw_pty(),
21161 Some(environment_profile.clone()),
21162 );
21163 assert!(request_requires_child_environment_profile(
21164 &shared,
21165 &NodeRequest::Input {
21166 session: address.clone(),
21167 text: "x".to_owned(),
21168 },
21169 ));
21170
21171 let mut record = record("claude", "profile-record");
21172 record.active_session = Some(address.clone());
21173 record.environment_profile = Some(environment_profile.clone());
21174 let record_id = record.record_id.clone();
21175 shared.insert_record(record.clone()).unwrap();
21176 assert!(request_requires_child_environment_profile(
21177 &shared,
21178 &NodeRequest::ResumeSessionRecord {
21179 record_id,
21180 terminal_size: gate4agent_types::TerminalSize {
21181 rows: 24,
21182 columns: 80,
21183 },
21184 initial_prompt: None,
21185 },
21186 ));
21187
21188 let snapshot = snapshot_for_wire(
21189 &shared, false, true, true, true, false, false, false, false, true,
21190 );
21191 assert_eq!(snapshot.session_records.len(), 1);
21192 assert!(snapshot.session_records[0].environment_profile.is_none());
21193 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21194 sequence: 1,
21195 event: NodeEvent::SessionRecordUpserted { record },
21196 });
21197 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21198 panic!("record projection changed the event kind");
21199 };
21200 assert!(record.environment_profile.is_none());
21201
21202 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21203 let mut reply = ResponseEnvelope {
21204 request_id: 1,
21205 result: Ok(NodeResponse::SpawnSpecAccepted {
21206 receipt: resolved.receipt_with_environment(
21207 shared.incarnation_id,
21208 address,
21209 Some(environment_profile),
21210 ),
21211 }),
21212 };
21213 project_response_without_child_environment_profile(&mut reply);
21214 assert_eq!(
21215 reply.result.unwrap_err().code,
21216 NodeFailureCode::UnsupportedCapability,
21217 );
21218 }
21219
21220 #[test]
21221 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21222 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21223 let snapshot = shared.snapshot();
21224 let profiles = snapshot
21225 .launch_inventory
21226 .as_ref()
21227 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21228 .unwrap();
21229 assert_eq!(profiles.len(), 1);
21230 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21231 assert!(snapshot.requires_child_environment_profile_capability());
21232
21233 let mut legacy = snapshot.clone();
21234 clear_snapshot_child_environment_profiles(&mut legacy);
21235 assert!(legacy
21236 .launch_inventory
21237 .as_ref()
21238 .unwrap()
21239 .spawn_profiles
21240 .as_ref()
21241 .unwrap()[0]
21242 .environment_profile
21243 .is_none());
21244 assert!(!legacy.requires_child_environment_profile_capability());
21245
21246 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21247 let bindings = shared
21248 .environment_profiles
21249 .read()
21250 .unwrap_or_else(|poisoned| poisoned.into_inner())
21251 .clone();
21252 let mut provider_mismatch = bindings.clone();
21253 provider_mismatch
21254 .values_mut()
21255 .next()
21256 .unwrap()
21257 .provider = agent("codex");
21258 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21259 let mut mode_mismatch = profile;
21260 mode_mismatch.mode = SessionMode::Inline;
21261 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21262
21263 shared
21264 .environment_profiles
21265 .write()
21266 .unwrap_or_else(|poisoned| poisoned.into_inner())
21267 .clear();
21268 assert!(shared
21269 .snapshot()
21270 .launch_inventory
21271 .unwrap()
21272 .spawn_profiles
21273 .unwrap()
21274 .is_empty());
21275 }
21276
21277 #[test]
21278 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21279 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21280 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21281 }));
21282 let shared = terminal_test_shared();
21283 let bundle = ResolvedBundleReceipt {
21284 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21285 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21286 digest: crate::protocol::SpawnBundleDigest::new(format!(
21287 "sha256:{}",
21288 "0".repeat(64),
21289 ))
21290 .unwrap(),
21291 };
21292 let mut bundled = record("claude", "bundle-record");
21293 bundled.bundle = Some(bundle.clone());
21294 let record_id = bundled.record_id.clone();
21295 shared.insert_record(bundled.clone()).unwrap();
21296 assert!(request_requires_session_bundle(
21297 &shared,
21298 &NodeRequest::ForgetSessionRecord {
21299 record_id: record_id.clone(),
21300 },
21301 ));
21302
21303 let snapshot = snapshot_for_wire(
21304 &shared, false, true, true, true, true, false, false, false, true,
21305 );
21306 assert!(snapshot.session_records[0].bundle.is_none());
21307 let launch = snapshot.launch_inventory.as_ref().unwrap();
21308 assert!(launch.spawn_profiles.is_some());
21309 assert!(launch.bundles.is_none());
21310 let event = project_event_without_session_bundle(NodeEventEnvelope {
21311 sequence: 1,
21312 event: NodeEvent::SessionRecordUpserted { record: bundled },
21313 });
21314 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21315 panic!("record projection changed the event kind");
21316 };
21317 assert!(record.bundle.is_none());
21318
21319 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21320 let mut reply = ResponseEnvelope {
21321 request_id: 1,
21322 result: Ok(NodeResponse::SpawnSpecAccepted {
21323 receipt: resolved.receipt_with_materialization(
21324 shared.incarnation_id,
21325 terminal_address(17),
21326 None,
21327 Some(bundle),
21328 None,
21329 ),
21330 }),
21331 };
21332 project_response_without_session_bundle(&mut reply);
21333 assert_eq!(
21334 reply.result.unwrap_err().code,
21335 NodeFailureCode::UnsupportedCapability,
21336 );
21337 }
21338
21339 #[test]
21340 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21341 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21342 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21343 }));
21344 let context_id = SpawnContextId::new("context-review").unwrap();
21345 let context = ResolvedContextPackReceipt {
21346 id: context_id.clone(),
21347 digest: crate::protocol::SpawnContextDigest::new(format!(
21348 "sha256:{}",
21349 "c".repeat(64),
21350 ))
21351 .unwrap(),
21352 lineage: ContextPackLineageReceipt {
21353 source_node_id: NodeId::new("node-source").unwrap(),
21354 source_session: terminal_address(1),
21355 source_provider: agent("kimi"),
21356 },
21357 source_message_count: 2,
21358 retained_message_count: 2,
21359 byte_len: 256,
21360 truncated: false,
21361 };
21362 assert!(context.is_valid());
21363 let mut bound_record = record("codex", "context-record");
21364 bound_record.context_id = Some(context_id);
21365 bound_record.context = Some(context.clone());
21366 let session = gate4agent_types::SessionSnapshot {
21367 instance_id: AgentInstanceId(91),
21368 agent_id: agent("kimi"),
21369 transport: TransportKind::Pty,
21370 generation: SessionGeneration(3),
21371 status: SessionStatus::Running,
21372 pending_operation: None,
21373 pending_input: None,
21374 process_id: Some(9001),
21375 terminal_size: None,
21376 terminal_frame: None,
21377 terminal_stale: None,
21378 session_options: None,
21379 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21380 history: gate4agent_types::HistorySnapshot {
21381 pending: None,
21382 candidates: vec![HistoryCandidateSummary {
21383 id: "candidate-opaque".to_owned(),
21384 session_id_hint: "session-hint".to_owned(),
21385 modified_at_unix_ms: Some(1),
21386 }],
21387 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21388 loaded: Some(HistorySessionRecord {
21389 session_id: "private-vendor-session".to_owned(),
21390 title: Some("private title".to_owned()),
21391 cwd: Some(r"C:\private\history-root".to_owned()),
21392 model: Some("private-model".to_owned()),
21393 message_count: 2,
21394 completed_turn_count: None,
21395 total_tokens: 19,
21396 messages: vec![
21397 gate4agent_types::HistoryMessageRecord {
21398 role: gate4agent_types::HistoryMessageRole::User,
21399 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21400 },
21401 gate4agent_types::HistoryMessageRecord {
21402 role: gate4agent_types::HistoryMessageRole::Assistant,
21403 text: "private answer".to_owned(),
21404 },
21405 ],
21406 }),
21407 last_error: None,
21408 },
21409 resume: gate4agent_types::ResumeSnapshot::default(),
21410 foreground: gate4agent_types::ForegroundSnapshot::default(),
21411 provider: gate4agent_types::ProviderSnapshot::default(),
21412 screen_state: gate4agent_types::PtyScreenState::default(),
21413 };
21414 let original = NodeSnapshot {
21415 node_id: NodeId::new("node-terminal-test").unwrap(),
21416 enabled_providers: vec![agent("kimi"), agent("codex")],
21417 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21418 workspaces: vec![WorkspaceSnapshot {
21419 workspace_id: WorkspaceId::new("primary").unwrap(),
21420 canonical_root: opaque_windows_path(
21421 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21422 ),
21423 sessions: vec![session],
21424 worktree_service_mode: None,
21425 managed_worktree_profiles: None,
21426 }],
21427 session_records: vec![bound_record],
21428 managed_worktrees: Vec::new(),
21429 launch_inventory: None,
21430 agent_progress: Vec::new(),
21431 };
21432
21433 let mut negotiated = original.clone();
21434 project_snapshot_history_for_wire(&mut negotiated, true);
21435 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21436 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21437 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21438 assert!(!serde_json::to_string(&negotiated)
21439 .unwrap()
21440 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21441
21442 let mut legacy = original;
21443 project_snapshot_history_for_wire(&mut legacy, false);
21444 clear_snapshot_context_packs(&mut legacy);
21445 assert_eq!(
21446 legacy.workspaces[0].sessions[0].history,
21447 gate4agent_types::HistorySnapshot::default(),
21448 );
21449 assert!(legacy.session_records[0].context_id.is_none());
21450 assert!(legacy.session_records[0].context.is_none());
21451 let encoded = serde_json::to_string(&legacy).unwrap();
21452 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21453 assert!(!encoded.contains("context-review"));
21454
21455 let mut reply = ResponseEnvelope {
21456 request_id: 7,
21457 result: Ok(NodeResponse::ContextPackExported { context }),
21458 };
21459 project_response_without_context_pack(&mut reply);
21460 assert_eq!(
21461 reply.result.unwrap_err().code,
21462 NodeFailureCode::UnsupportedCapability,
21463 );
21464 }
21465
21466 #[test]
21467 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21468 let shared = terminal_test_shared();
21469 let history = HistorySessionRecord {
21470 session_id: "fixture-history".to_owned(),
21471 title: None,
21472 cwd: None,
21473 model: None,
21474 message_count: 1,
21475 completed_turn_count: None,
21476 total_tokens: 0,
21477 messages: vec![gate4agent_types::HistoryMessageRecord {
21478 role: gate4agent_types::HistoryMessageRole::User,
21479 text: "bounded handoff".to_owned(),
21480 }],
21481 };
21482 let pack = NodeContextPack::export(
21483 ContextPackLineageReceipt {
21484 source_node_id: shared.node_id.clone(),
21485 source_session: terminal_address(1),
21486 source_provider: agent("claude"),
21487 },
21488 &history,
21489 )
21490 .unwrap();
21491 let context = shared
21492 .context_catalog
21493 .write()
21494 .unwrap_or_else(|poisoned| poisoned.into_inner())
21495 .insert(pack)
21496 .unwrap();
21497 let mut retained = record("codex", "context-retained-record");
21498 retained.context_id = Some(context.id.clone());
21499 retained.context = Some(context.clone());
21500 let record_id = retained.record_id.clone();
21501 shared.insert_record(retained).unwrap();
21502 assert_eq!(
21503 shared.forget_context_pack(&context.id).unwrap_err().code,
21504 NodeFailureCode::ContextPackBusy,
21505 );
21506 assert!(shared
21507 .context_catalog
21508 .read()
21509 .unwrap_or_else(|poisoned| poisoned.into_inner())
21510 .get(&context.id)
21511 .is_some());
21512 shared
21513 .session_records
21514 .lock()
21515 .unwrap_or_else(|poisoned| poisoned.into_inner())
21516 .records
21517 .remove(&record_id);
21518
21519 let mut spec = spawn_spec_fixture();
21520 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21521 value: context.id.clone(),
21522 };
21523 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21524 let receipt = resolved.receipt_with_materialization(
21525 shared.incarnation_id,
21526 terminal_address(2),
21527 None,
21528 None,
21529 Some(context.clone()),
21530 );
21531 assert!(receipt.context_binding_is_valid());
21532 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21533
21534 shared.forget_context_pack(&context.id).unwrap();
21535 assert!(shared
21536 .context_catalog
21537 .read()
21538 .unwrap_or_else(|poisoned| poisoned.into_inner())
21539 .get(&context.id)
21540 .is_none());
21541 assert!(shared
21542 .replay_spawn_spec(&spec, Instant::now())
21543 .unwrap()
21544 .is_none());
21545 }
21546
21547 #[test]
21548 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21549 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21550 let address = terminal_address(1);
21551 let selection = shared
21552 .select_environment_profile(
21553 address.session.instance_id,
21554 &agent("claude"),
21555 SessionMode::Pty,
21556 Some(&environment_profile),
21557 )
21558 .unwrap()
21559 .unwrap();
21560 shared.bind_session_with_policy(
21561 &address,
21562 ProviderRuntimePolicy::raw_pty(),
21563 Some(environment_profile.clone()),
21564 );
21565 selection.retain();
21566 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21567 assert!(matches!(
21568 runtime.remove_native_launch_profile(&native_id),
21569 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21570 ));
21571 assert!(shared.remove_binding(&address).is_some());
21572 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21573
21574 let mismatched = ResolvedEnvironmentProfileReceipt {
21575 profile_id: environment_profile.profile_id,
21576 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21577 "local-claude-r2",
21578 )
21579 .unwrap(),
21580 network_allowlist: None,
21581 browser_profile_id: None,
21582 };
21583 let error = match shared.select_environment_profile(
21584 AgentInstanceId(42),
21585 &agent("claude"),
21586 SessionMode::Pty,
21587 Some(&mismatched),
21588 ) {
21589 Ok(_) => panic!("mismatched environment profile revision was selected"),
21590 Err(error) => error,
21591 };
21592 assert_eq!(
21593 error.code,
21594 NodeFailureCode::EnvironmentProfileBindingMismatch,
21595 );
21596 assert!(!shared
21597 .session_bindings
21598 .lock()
21599 .unwrap_or_else(|poisoned| poisoned.into_inner())
21600 .contains_key(&AgentInstanceId(42)));
21601 }
21602
21603 #[test]
21604 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21605 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21606 let address = terminal_address(1);
21607 let selection = shared
21608 .select_environment_profile(
21609 address.session.instance_id,
21610 &agent("claude"),
21611 SessionMode::Pty,
21612 Some(&environment_profile),
21613 )
21614 .unwrap()
21615 .unwrap();
21616 let overlay = NativeInstanceLaunchOverlay::new(
21617 agent("claude"),
21618 TransportKind::Pty,
21619 vec![EnvMutation {
21620 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21621 value: Some(OsString::from("fixture-secret")),
21622 }],
21623 vec![
21624 OsString::from("--plugin-dir"),
21625 std::env::current_dir().unwrap().into_os_string(),
21626 ],
21627 )
21628 .unwrap();
21629 let instance_overlay = shared
21630 .install_prepared_launch_overlay(
21631 address.session.instance_id,
21632 PreparedNativeLaunchOverlay::Instance(overlay),
21633 )
21634 .unwrap()
21635 .unwrap();
21636 let bundle = ResolvedBundleReceipt {
21637 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21638 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21639 digest: crate::protocol::SpawnBundleDigest::new(format!(
21640 "sha256:{}",
21641 "0".repeat(64),
21642 ))
21643 .unwrap(),
21644 };
21645 shared.bind_session_with_materialization(
21646 &address,
21647 ProviderRuntimePolicy::raw_pty(),
21648 Some(environment_profile),
21649 Some(bundle),
21650 None,
21651 None,
21652 );
21653 selection.retain();
21654 instance_overlay.retain();
21655
21656 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21657 assert!(matches!(
21658 runtime.remove_native_launch_profile(&native_id),
21659 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21660 ));
21661 assert!(shared.remove_binding(&address).is_some());
21662 assert!(!shared
21663 .native_launch_profile_control
21664 .as_ref()
21665 .unwrap()
21666 .clear_native_instance_launch_overlay(address.session.instance_id));
21667 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21668 }
21669
21670 #[test]
21671 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21672 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21673 let address = SessionAddress {
21674 workspace_id: WorkspaceId::new("primary").unwrap(),
21675 session: SessionKey {
21676 instance_id: AgentInstanceId(201),
21677 generation: SessionGeneration(1),
21678 },
21679 };
21680 let error = match shared.prepare_session_materialization(
21681 &address,
21682 &agent("claude"),
21683 SessionMode::Pty,
21684 Some(&receipt),
21685 None,
21686 None,
21687 None,
21688 ) {
21689 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21690 Err(error) => error,
21691 };
21692 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21693 assert!(shared.handle.snapshot().sessions.is_empty());
21694 assert!(shared
21695 .session_bindings
21696 .lock()
21697 .unwrap_or_else(|poisoned| poisoned.into_inner())
21698 .is_empty());
21699 assert!(shared
21700 .materializations
21701 .lock()
21702 .unwrap_or_else(|poisoned| poisoned.into_inner())
21703 .is_empty());
21704 drop(shared);
21705 drop(runtime);
21706 std::fs::remove_dir_all(root).unwrap();
21707 }
21708
21709 #[test]
21710 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21711 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21712 let address = SessionAddress {
21713 workspace_id: WorkspaceId::new("primary").unwrap(),
21714 session: SessionKey {
21715 instance_id: AgentInstanceId(202),
21716 generation: SessionGeneration(1),
21717 },
21718 };
21719 let (overlay, materialization_guard) = shared
21720 .prepare_session_materialization(
21721 &address,
21722 &agent("claude"),
21723 SessionMode::Pty,
21724 Some(&receipt),
21725 None,
21726 None,
21727 None,
21728 )
21729 .unwrap()
21730 .unwrap();
21731 let materialization_id = materialization_guard.id().unwrap().clone();
21732 let materialization_root = shared
21733 .materializations
21734 .lock()
21735 .unwrap_or_else(|poisoned| poisoned.into_inner())
21736 .get(&materialization_id)
21737 .unwrap()
21738 .root()
21739 .to_path_buf();
21740 let selection = shared
21741 .select_environment_profile(
21742 address.session.instance_id,
21743 &agent("claude"),
21744 SessionMode::Pty,
21745 Some(&receipt),
21746 )
21747 .unwrap()
21748 .unwrap();
21749 assert!(shared
21750 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21751 .unwrap()
21752 .is_none());
21753 shared.bind_session_with_materialization(
21754 &address,
21755 ProviderRuntimePolicy::raw_pty(),
21756 Some(receipt),
21757 None,
21758 None,
21759 Some(materialization_id),
21760 );
21761 selection.retain();
21762 materialization_guard.retain();
21763
21764 assert!(materialization_root.is_dir());
21765 assert!(shared
21766 .session_bindings
21767 .lock()
21768 .unwrap_or_else(|poisoned| poisoned.into_inner())
21769 .contains_key(&address.session.instance_id));
21770
21771 let removed = shared.remove_binding(&address).unwrap();
21772 shared
21773 .cleanup_session_owned_materialization(&address, &removed)
21774 .unwrap();
21775 assert!(!materialization_root.exists());
21776 assert!(shared
21777 .materializations
21778 .lock()
21779 .unwrap_or_else(|poisoned| poisoned.into_inner())
21780 .is_empty());
21781 drop(shared);
21782 drop(runtime);
21783 std::fs::remove_dir_all(root).unwrap();
21784 }
21785
21786 #[tokio::test]
21787 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21788 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21789 let address = SessionAddress {
21790 workspace_id: WorkspaceId::new("primary").unwrap(),
21791 session: SessionKey {
21792 instance_id: AgentInstanceId(203),
21793 generation: SessionGeneration(1),
21794 },
21795 };
21796 let (overlay, materialization_guard) = shared
21797 .prepare_session_materialization(
21798 &address,
21799 &agent("claude"),
21800 SessionMode::Pty,
21801 Some(&receipt),
21802 None,
21803 None,
21804 None,
21805 )
21806 .unwrap()
21807 .unwrap();
21808 let materialization_id = materialization_guard.id().unwrap().clone();
21809 let materialization_root = shared
21810 .materializations
21811 .lock()
21812 .unwrap_or_else(|poisoned| poisoned.into_inner())
21813 .get(&materialization_id)
21814 .unwrap()
21815 .root()
21816 .to_path_buf();
21817 let selection = shared
21818 .select_environment_profile(
21819 address.session.instance_id,
21820 &agent("claude"),
21821 SessionMode::Pty,
21822 Some(&receipt),
21823 )
21824 .unwrap()
21825 .unwrap();
21826 assert!(shared
21827 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21828 .unwrap()
21829 .is_none());
21830 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21831 let record_id = shared
21832 .bind_spawn_session_with_materialization(
21833 &address,
21834 agent("claude"),
21835 SessionMode::Pty,
21836 policy,
21837 SpawnRecordPolicy::ProviderIdentityOnly,
21838 Some(receipt.clone()),
21839 None,
21840 None,
21841 Some(materialization_id.clone()),
21842 )
21843 .unwrap()
21844 .unwrap();
21845 selection.retain();
21846 materialization_guard.retain();
21847 assert_eq!(
21848 shared
21849 .materializations
21850 .lock()
21851 .unwrap_or_else(|poisoned| poisoned.into_inner())
21852 .get(&materialization_id)
21853 .unwrap()
21854 .owner(),
21855 &MaterializationOwner::Record {
21856 record_id: record_id.clone(),
21857 },
21858 );
21859
21860 let removed = shared.remove_binding(&address).unwrap();
21861 shared
21862 .cleanup_session_owned_materialization(&address, &removed)
21863 .unwrap();
21864 assert!(materialization_root.is_dir());
21865 assert!(shared
21866 .resolve_record_materialization(
21867 &record_id,
21868 &agent("claude"),
21869 SessionMode::Pty,
21870 Some(&receipt),
21871 None,
21872 None,
21873 )
21874 .unwrap()
21875 .is_some());
21876 {
21877 let mut records = shared
21878 .session_records
21879 .lock()
21880 .unwrap_or_else(|poisoned| poisoned.into_inner());
21881 let record = records.records.get_mut(&record_id).unwrap();
21882 record.active_session = None;
21883 record.state = ManagedSessionState::Unavailable;
21884 }
21885 shared.forget_session_record(&record_id).await.unwrap();
21886 assert!(!materialization_root.exists());
21887 assert!(shared
21888 .materializations
21889 .lock()
21890 .unwrap_or_else(|poisoned| poisoned.into_inner())
21891 .is_empty());
21892 drop(shared);
21893 drop(runtime);
21894 std::fs::remove_dir_all(root).unwrap();
21895 }
21896
21897 #[test]
21898 fn record_materialization_revalidation_failure_persists_recovery_required() {
21899 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21900 let state_path = root.join("node-state-v6.json");
21901 shared.state_path = Some(state_path.clone());
21902 let (_address, _record_id, materialization_id, materialization_root) =
21903 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21904 std::fs::write(
21905 materialization_root.join(".gate4agent-materialization-owner"),
21906 b"tampered",
21907 )
21908 .unwrap();
21909
21910 let error = match shared.resolve_record_materialization(
21911 &_record_id,
21912 &agent("claude"),
21913 SessionMode::Pty,
21914 Some(&receipt),
21915 None,
21916 None,
21917 ) {
21918 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21919 Err(error) => error,
21920 };
21921 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21922 assert_eq!(
21923 shared
21924 .materializations
21925 .lock()
21926 .unwrap_or_else(|poisoned| poisoned.into_inner())
21927 .get(&materialization_id)
21928 .unwrap()
21929 .state(),
21930 MaterializationState::RecoveryRequired,
21931 );
21932 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21933 assert_eq!(loaded.materializations.len(), 1);
21934 assert_eq!(
21935 loaded.materializations[0].state(),
21936 MaterializationState::RecoveryRequired,
21937 );
21938 drop(shared);
21939 drop(runtime);
21940 std::fs::remove_dir_all(root).unwrap();
21941 }
21942
21943 #[test]
21944 fn transient_record_materialization_resolution_failure_remains_ready() {
21945 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21946 let (_address, record_id, materialization_id, _materialization_root) =
21947 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21948 deny.store(true, Ordering::Release);
21949
21950 let error = match shared.resolve_record_materialization(
21951 &record_id,
21952 &agent("claude"),
21953 SessionMode::Pty,
21954 Some(&receipt),
21955 None,
21956 None,
21957 ) {
21958 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21959 Err(error) => error,
21960 };
21961 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21962 assert_eq!(
21963 shared
21964 .materializations
21965 .lock()
21966 .unwrap_or_else(|poisoned| poisoned.into_inner())
21967 .get(&materialization_id)
21968 .unwrap()
21969 .state(),
21970 MaterializationState::Ready,
21971 );
21972 drop(shared);
21973 drop(runtime);
21974 std::fs::remove_dir_all(root).unwrap();
21975 }
21976
21977 #[test]
21978 fn startup_missing_record_materialization_is_not_resumable() {
21979 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21980 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21981 shared
21982 .insert_record(ManagedSessionRecord {
21983 record_id: record_id.clone(),
21984 display_name: "missing materialization".to_owned(),
21985 provider: agent("claude"),
21986 mode: SessionMode::Pty,
21987 state: ManagedSessionState::Dormant,
21988 workspace_id: WorkspaceId::new("primary").unwrap(),
21989 canonical_root: opaque_windows_path(
21990 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21991 ),
21992 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21993 key: gate4agent_types::ProviderSessionKey::SessionId,
21994 id: "provider-session-missing-materialization".to_owned(),
21995 transcript_path: None,
21996 }),
21997 active_session: None,
21998 environment_profile: Some(receipt),
21999 bundle: None,
22000 context_id: None,
22001 context: None,
22002 exported_context: None,
22003 task_binding: None,
22004 created_at_unix_ms: 1,
22005 updated_at_unix_ms: 1,
22006 last_error: None,
22007 })
22008 .unwrap();
22009
22010 shared.reconcile_materializations();
22011 let record = shared.record(&record_id).unwrap();
22012 assert_eq!(record.state, ManagedSessionState::Unavailable);
22013 assert!(record.provider_session.is_none());
22014 assert_eq!(
22015 record.last_error.as_deref(),
22016 Some("environment-profile-unavailable"),
22017 );
22018 drop(shared);
22019 drop(runtime);
22020 std::fs::remove_dir_all(root).unwrap();
22021 }
22022
22023 #[tokio::test]
22024 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22025 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22026 let lease = install_managed_lease(&shared);
22027 let address = SessionAddress {
22028 workspace_id: WorkspaceId::new("primary").unwrap(),
22029 session: SessionKey {
22030 instance_id: AgentInstanceId(204),
22031 generation: SessionGeneration(1),
22032 },
22033 };
22034 let (overlay, materialization_guard) = shared
22035 .prepare_session_materialization(
22036 &address,
22037 &agent("claude"),
22038 SessionMode::Pty,
22039 Some(&receipt),
22040 None,
22041 None,
22042 Some(lease.lease_id.clone()),
22043 )
22044 .unwrap()
22045 .unwrap();
22046 let materialization_id = materialization_guard.id().unwrap().clone();
22047 let materialization_root = shared
22048 .materializations
22049 .lock()
22050 .unwrap_or_else(|poisoned| poisoned.into_inner())
22051 .get(&materialization_id)
22052 .unwrap()
22053 .root()
22054 .to_path_buf();
22055 let selection = shared
22056 .select_environment_profile(
22057 address.session.instance_id,
22058 &agent("claude"),
22059 SessionMode::Pty,
22060 Some(&receipt),
22061 )
22062 .unwrap()
22063 .unwrap();
22064 assert!(shared
22065 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22066 .unwrap()
22067 .is_none());
22068 shared.bind_session_with_materialization(
22069 &address,
22070 ProviderRuntimePolicy::raw_pty(),
22071 Some(receipt),
22072 None,
22073 None,
22074 Some(materialization_id),
22075 );
22076 selection.retain();
22077 materialization_guard.retain();
22078
22079 let blocked = shared
22080 .cleanup_managed_worktree(&lease.lease_id, false)
22081 .await
22082 .unwrap_err();
22083 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22084 assert!(materialization_root.is_dir());
22085 let removed = shared.remove_binding(&address).unwrap();
22086 shared
22087 .cleanup_session_owned_materialization(&address, &removed)
22088 .unwrap();
22089 assert!(!materialization_root.exists());
22090 drop(shared);
22091 drop(runtime);
22092 std::fs::remove_dir_all(root).unwrap();
22093 }
22094
22095 #[tokio::test]
22096 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22097 let git_root = temporary_workspace_root("raw-managed-inventory");
22098 let source = git_root.join("source");
22099 let allocation = git_root.join("allocation");
22100 std::fs::create_dir_all(&source).unwrap();
22101 std::fs::create_dir_all(&allocation).unwrap();
22102 run_test_git(&source, &["init"]);
22103 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22104 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22105 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22106 run_test_git(&source, &["add", "seed.txt"]);
22107 run_test_git(&source, &["commit", "-m", "seed"]);
22108 let profile = ManagedWorktreeProfile::new(
22109 WorktreeProfileId::new("default").unwrap(),
22110 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22111 &allocation,
22112 "gate4agent",
22113 "HEAD",
22114 ManagedWorktreeRetention::RemoveWhenReleased,
22115 )
22116 .unwrap();
22117 let workspace = WorkspaceConfig::new(
22118 WorkspaceId::new("primary").unwrap(),
22119 &source,
22120 )
22121 .unwrap()
22122 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22123 .with_managed_worktree_profile(profile.clone())
22124 .unwrap();
22125 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22126 materialization_test_shared_with_workspace(false, workspace);
22127 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22128 let source_text = source.to_string_lossy().into_owned();
22129 let target_text = target.to_string_lossy().into_owned();
22130 let created = create_git_worktree(
22131 &source_text,
22132 &target_text,
22133 "gate4agent/mw-raw-inventory",
22134 Some("HEAD"),
22135 )
22136 .await
22137 .unwrap();
22138 let lease = ManagedWorktreeLeaseRecord {
22139 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22140 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22141 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22142 profile_id: profile.profile_id().clone(),
22143 profile_revision: profile.revision().clone(),
22144 target_root: created.path.clone(),
22145 branch: "gate4agent/mw-raw-inventory".to_owned(),
22146 base_commit: created.head.clone(),
22147 expected_head: Some(created.head.clone()),
22148 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22149 state: ManagedWorktreeLeaseState::Ready,
22150 session_holders: Vec::new(),
22151 record_holders: Vec::new(),
22152 cleanup_failure: None,
22153 created_at_unix_ms: 1,
22154 updated_at_unix_ms: 1,
22155 };
22156 shared
22157 .workspaces
22158 .write()
22159 .unwrap_or_else(|poisoned| poisoned.into_inner())
22160 .insert(lease.workspace_id.clone(), created.path.clone());
22161 *shared
22162 .managed_worktrees
22163 .lock()
22164 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22165 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22166 let address = SessionAddress {
22167 workspace_id: lease.workspace_id.clone(),
22168 session: SessionKey {
22169 instance_id: AgentInstanceId(205),
22170 generation: SessionGeneration(1),
22171 },
22172 };
22173 let (overlay, materialization_guard) = shared
22174 .prepare_session_materialization(
22175 &address,
22176 &agent("claude"),
22177 SessionMode::Pty,
22178 Some(&receipt),
22179 None,
22180 None,
22181 Some(lease.lease_id.clone()),
22182 )
22183 .unwrap()
22184 .unwrap();
22185 let materialization_id = materialization_guard.id().unwrap().clone();
22186 let materialization_root = shared
22187 .materializations
22188 .lock()
22189 .unwrap_or_else(|poisoned| poisoned.into_inner())
22190 .get(&materialization_id)
22191 .unwrap()
22192 .root()
22193 .to_path_buf();
22194 let selection = shared
22195 .select_environment_profile(
22196 address.session.instance_id,
22197 &agent("claude"),
22198 SessionMode::Pty,
22199 Some(&receipt),
22200 )
22201 .unwrap()
22202 .unwrap();
22203 assert!(shared
22204 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22205 .unwrap()
22206 .is_none());
22207 let record_id = shared
22208 .bind_spawn_session_with_materialization(
22209 &address,
22210 agent("claude"),
22211 SessionMode::Pty,
22212 ProviderRuntimePolicy::raw_pty(),
22213 SpawnRecordPolicy::Always,
22214 Some(receipt.clone()),
22215 None,
22216 None,
22217 Some(materialization_id.clone()),
22218 )
22219 .unwrap()
22220 .unwrap();
22221 selection.retain();
22222 materialization_guard.retain();
22223 let record = shared.record(&record_id).unwrap();
22224 assert_eq!(record.state, ManagedSessionState::Live);
22225 assert!(record.provider_session.is_none());
22226 assert!(record.environment_profile.is_none());
22227 assert!(record.bundle.is_none());
22228 assert!(record.context_id.is_none());
22229 assert!(record.context.is_none());
22230 assert_eq!(record.active_session.as_ref(), Some(&address));
22231 assert_eq!(
22232 shared
22233 .materializations
22234 .lock()
22235 .unwrap_or_else(|poisoned| poisoned.into_inner())
22236 .get(&materialization_id)
22237 .unwrap()
22238 .owner(),
22239 &MaterializationOwner::Session {
22240 incarnation_id: shared.incarnation_id,
22241 instance_id: address.session.instance_id,
22242 generation: address.session.generation,
22243 },
22244 );
22245 {
22246 let mut bindings = shared
22247 .session_bindings
22248 .lock()
22249 .unwrap_or_else(|poisoned| poisoned.into_inner());
22250 bindings
22251 .get_mut(&address.session.instance_id)
22252 .unwrap()
22253 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22254 }
22255 let lease_snapshot = shared
22256 .managed_worktrees
22257 .lock()
22258 .unwrap_or_else(|poisoned| poisoned.into_inner())
22259 .bind_session(
22260 &lease.lease_id,
22261 ManagedWorktreeSessionHolder {
22262 incarnation_id: shared.incarnation_id,
22263 instance_id: address.session.instance_id,
22264 generation: address.session.generation,
22265 },
22266 None,
22267 unix_time_ms(),
22268 )
22269 .unwrap();
22270 assert_eq!(lease_snapshot.active_session_count, 1);
22271 assert_eq!(lease_snapshot.managed_record_count, 0);
22272 shared.persist_state().unwrap();
22273 assert!(shared
22274 .managed_worktrees
22275 .lock()
22276 .unwrap_or_else(|poisoned| poisoned.into_inner())
22277 .get(&lease.lease_id)
22278 .unwrap()
22279 .record_holders
22280 .is_empty());
22281 shared
22282 .handle
22283 .dispatch(shared.prepare_command(ControlCommand::Register {
22284 instance_id: address.session.instance_id,
22285 agent_id: agent("claude"),
22286 transport: TransportKind::Pty,
22287 }))
22288 .unwrap();
22289 runtime.tick().await;
22290 shared.reconcile_managed_record(
22291 &record_id,
22292 &address,
22293 &ControlEvent {
22294 sequence: 1,
22295 command_id: None,
22296 instance_id: address.session.instance_id,
22297 generation: address.session.generation,
22298 event: ControlEventKind::Removed,
22299 },
22300 false,
22301 false,
22302 );
22303 let remove = shared.remove_session(&address);
22304 let drive_runtime = async {
22305 runtime.tick().await;
22306 };
22307 let (removed, ()) = tokio::join!(remove, drive_runtime);
22308 removed.unwrap();
22309
22310 assert!(!materialization_root.exists());
22311 assert!(!Path::new(&created.path).exists());
22312 assert_eq!(
22313 shared
22314 .managed_worktrees
22315 .lock()
22316 .unwrap_or_else(|poisoned| poisoned.into_inner())
22317 .get(&lease.lease_id)
22318 .unwrap()
22319 .state,
22320 ManagedWorktreeLeaseState::Removed,
22321 );
22322 let renamed = shared
22323 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22324 .unwrap();
22325 assert_eq!(renamed.record_id, record_id);
22326 assert_eq!(renamed.display_name, "raw managed inventory");
22327 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22328 assert!(renamed.active_session.is_none());
22329
22330 drop(shared);
22331 drop(runtime);
22332 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22333 std::fs::remove_dir_all(git_root).unwrap();
22334 }
22335
22336 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22337 let root = std::env::current_dir().unwrap()
22338 .join("managed-test-target")
22339 .to_string_lossy()
22340 .into_owned();
22341 let lease = ManagedWorktreeLeaseRecord {
22342 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22343 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22344 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22345 profile_id: WorktreeProfileId::new("default").unwrap(),
22346 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22347 target_root: root.clone(),
22348 branch: "gate4agent/mw-test".to_owned(),
22349 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22350 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22351 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22352 state: ManagedWorktreeLeaseState::Ready,
22353 session_holders: Vec::new(),
22354 record_holders: Vec::new(),
22355 cleanup_failure: None,
22356 created_at_unix_ms: 1,
22357 updated_at_unix_ms: 1,
22358 };
22359 shared.workspaces.write()
22360 .unwrap_or_else(|poisoned| poisoned.into_inner())
22361 .insert(lease.workspace_id.clone(), root);
22362 *shared.managed_worktrees.lock()
22363 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22364 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22365 lease
22366 }
22367
22368 #[cfg(feature = "fixture")]
22369 #[test]
22370 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22371 let mut shared = terminal_test_shared();
22372 shared.fixture_semantic_hook_policy = true;
22373 let lease = install_managed_lease(&shared);
22374 let address = SessionAddress {
22375 workspace_id: lease.workspace_id.clone(),
22376 session: SessionKey {
22377 instance_id: AgentInstanceId(206),
22378 generation: SessionGeneration(1),
22379 },
22380 };
22381 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22382 let effective_runtime_policy =
22383 shared.effective_spawn_runtime_policy(pre_admission_policy);
22384 assert_ne!(effective_runtime_policy, pre_admission_policy);
22385 assert!(effective_runtime_policy.provider_session_identity);
22386
22387 let record_id = shared
22388 .bind_spawn_session(
22389 &address,
22390 agent("claude"),
22391 SessionMode::Pty,
22392 effective_runtime_policy,
22393 SpawnRecordPolicy::Always,
22394 None,
22395 )
22396 .unwrap()
22397 .unwrap();
22398 assert_eq!(
22399 shared.record(&record_id).unwrap().state,
22400 ManagedSessionState::IdentityPending,
22401 );
22402 let (record_holder, raw_inventory_record_id) = {
22403 let mut bindings = shared
22404 .session_bindings
22405 .lock()
22406 .unwrap_or_else(|poisoned| poisoned.into_inner());
22407 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22408 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22409 managed_spawn_record_ownership(binding, effective_runtime_policy)
22410 };
22411 assert_eq!(record_holder.as_ref(), Some(&record_id));
22412 assert!(raw_inventory_record_id.is_none());
22413 let snapshot = shared
22414 .managed_worktrees
22415 .lock()
22416 .unwrap_or_else(|poisoned| poisoned.into_inner())
22417 .bind_session(
22418 &lease.lease_id,
22419 ManagedWorktreeSessionHolder {
22420 incarnation_id: shared.incarnation_id,
22421 instance_id: address.session.instance_id,
22422 generation: address.session.generation,
22423 },
22424 record_holder,
22425 unix_time_ms(),
22426 )
22427 .unwrap();
22428 assert_eq!(snapshot.managed_record_count, 1);
22429 assert_eq!(
22430 shared
22431 .managed_worktrees
22432 .lock()
22433 .unwrap_or_else(|poisoned| poisoned.into_inner())
22434 .get(&lease.lease_id)
22435 .unwrap()
22436 .record_holders,
22437 vec![record_id.clone()],
22438 );
22439
22440 let identity = ProviderSessionIdentity {
22441 key: ProviderSessionKey::SessionId,
22442 id: "monitoring-fixture-managed-session".to_owned(),
22443 transcript_path: None,
22444 };
22445 shared.publish_control(ControlEvent {
22446 sequence: 1,
22447 command_id: None,
22448 instance_id: address.session.instance_id,
22449 generation: address.session.generation,
22450 event: ControlEventKind::ProviderEvent {
22451 sequence: 1,
22452 source: gate4agent_types::ProviderSource {
22453 family: AdapterFamily::Hook,
22454 binding: AdapterBinding::new(
22455 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22456 "fixture/v1",
22457 gate4agent_types::AdapterVerification::SyntheticFixture,
22458 )
22459 .unwrap(),
22460 },
22461 source_sequence: 1,
22462 event: ProviderEvent::SessionIdentityObserved {
22463 identity: identity.clone(),
22464 },
22465 },
22466 });
22467
22468 let live = shared.record(&record_id).unwrap();
22469 assert_eq!(live.state, ManagedSessionState::Live);
22470 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22471 assert_eq!(live.active_session.as_ref(), Some(&address));
22472 assert_eq!(
22473 shared
22474 .managed_worktrees
22475 .lock()
22476 .unwrap_or_else(|poisoned| poisoned.into_inner())
22477 .get(&lease.lease_id)
22478 .unwrap()
22479 .record_holders,
22480 vec![record_id],
22481 );
22482 }
22483
22484 fn managed_v2_test_shared(
22485 allocation_root: &Path,
22486 state_path: Option<PathBuf>,
22487 profile_revision: &str,
22488 ) -> NodeShared {
22489 let profile = ManagedWorktreeProfile::new(
22490 WorktreeProfileId::new("default").unwrap(),
22491 WorktreeProfileRevision::new(profile_revision).unwrap(),
22492 allocation_root,
22493 "gate4agent",
22494 "HEAD",
22495 ManagedWorktreeRetention::RemoveWhenReleased,
22496 )
22497 .unwrap();
22498 let workspace = WorkspaceConfig::new(
22499 WorkspaceId::new("primary").unwrap(),
22500 std::env::current_dir().unwrap(),
22501 )
22502 .unwrap()
22503 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22504 .with_managed_worktree_profile(profile)
22505 .unwrap();
22506 let catalog = active_registry().unwrap();
22507 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22508 let mut shared = NodeShared::new(
22509 handle,
22510 "fixture-token".to_owned(),
22511 NodeId::new("node-terminal-test").unwrap(),
22512 vec![workspace],
22513 vec![agent("claude")],
22514 );
22515 shared.state_path = state_path;
22516 shared
22517 }
22518
22519 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22520 let mut spawn_spec = spawn_spec_fixture();
22521 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22522 ManagedWorktreeSpawnRequestV2 {
22523 spawn_spec,
22524 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22525 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22526 }
22527 }
22528
22529 #[test]
22530 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22531 let shared = terminal_test_shared();
22532 let mut lease = install_managed_lease(&shared);
22533 let session = SessionAddress {
22534 workspace_id: lease.workspace_id.clone(),
22535 session: SessionKey {
22536 instance_id: AgentInstanceId(73),
22537 generation: SessionGeneration(4),
22538 },
22539 };
22540 lease.state = ManagedWorktreeLeaseState::InUse;
22541 lease.session_holders.push(ManagedWorktreeSessionHolder {
22542 incarnation_id: shared.incarnation_id,
22543 instance_id: session.session.instance_id,
22544 generation: session.session.generation,
22545 });
22546 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22547 assert!(resolved.target.worktree_id.is_none());
22548
22549 let receipt = managed_spawn_receipt(
22550 &resolved,
22551 shared.incarnation_id,
22552 session.clone(),
22553 lease.snapshot(),
22554 None,
22555 None,
22556 None,
22557 );
22558 assert!(resolved.target.worktree_id.is_none());
22559 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22560 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22561 assert_eq!(receipt.spawn.session, session);
22562 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22563 let encoded = serde_json::to_vec(&receipt).unwrap();
22564 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22565 assert_eq!(decoded, receipt);
22566 }
22567
22568 #[tokio::test]
22569 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22570 let root = temporary_workspace_root("managed-v2-revision");
22571 let allocation = root.join("allocation");
22572 std::fs::create_dir_all(&allocation).unwrap();
22573 let shared = managed_v2_test_shared(
22574 &allocation,
22575 Some(root.join("node-state.json")),
22576 "v1",
22577 );
22578 let mut request = managed_v2_request("managed-v2-revision");
22579 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22580
22581 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22582
22583 assert_eq!(
22584 failure.code,
22585 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22586 );
22587 assert!(shared
22588 .managed_worktrees
22589 .lock()
22590 .unwrap_or_else(|poisoned| poisoned.into_inner())
22591 .records()
22592 .is_empty());
22593 assert!(shared
22594 .managed_spawn_replays
22595 .lock()
22596 .unwrap_or_else(|poisoned| poisoned.into_inner())
22597 .is_empty());
22598 std::fs::remove_dir_all(root).unwrap();
22599 }
22600
22601 #[test]
22602 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22603 let root = temporary_workspace_root("managed-v2-concurrent");
22604 let allocation = root.join("allocation");
22605 std::fs::create_dir_all(&allocation).unwrap();
22606 let shared = Arc::new(managed_v2_test_shared(
22607 &allocation,
22608 Some(root.join("node-state.json")),
22609 "v1",
22610 ));
22611 let request = managed_v2_request("managed-v2-concurrent");
22612 let barrier = Arc::new(std::sync::Barrier::new(3));
22613 let mut joins = Vec::new();
22614 for _ in 0..2 {
22615 let shared = Arc::clone(&shared);
22616 let request = request.clone();
22617 let barrier = Arc::clone(&barrier);
22618 joins.push(std::thread::spawn(move || {
22619 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22620 barrier.wait();
22621 shared.acquire_durable_managed_spawn_v2(&request, digest)
22622 }));
22623 }
22624 barrier.wait();
22625 let results = joins
22626 .into_iter()
22627 .map(|join| join.join().unwrap())
22628 .collect::<Vec<_>>();
22629 assert_eq!(
22630 results
22631 .iter()
22632 .filter(|result| {
22633 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22634 })
22635 .count(),
22636 1,
22637 );
22638 assert_eq!(
22639 results
22640 .iter()
22641 .filter(|result| {
22642 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22643 })
22644 .count(),
22645 1,
22646 );
22647 assert_eq!(
22648 shared
22649 .managed_spawn_replays
22650 .lock()
22651 .unwrap_or_else(|poisoned| poisoned.into_inner())
22652 .len(),
22653 1,
22654 );
22655 assert!(shared
22656 .managed_worktrees
22657 .lock()
22658 .unwrap_or_else(|poisoned| poisoned.into_inner())
22659 .records()
22660 .is_empty());
22661 drop(shared);
22662 std::fs::remove_dir_all(root).unwrap();
22663 }
22664
22665 #[tokio::test]
22666 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22667 let root = temporary_workspace_root("managed-v2-linked-pending");
22668 let allocation = root.join("allocation");
22669 std::fs::create_dir_all(&allocation).unwrap();
22670 let state_path = root.join("node-state.json");
22671 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22672 let lease = install_managed_lease(&shared);
22673 let request = managed_v2_request("managed-v2-linked-pending");
22674 assert!(matches!(
22675 shared
22676 .acquire_durable_managed_spawn_v2(
22677 &request,
22678 managed_spawn_request_digest_v2(&request).unwrap(),
22679 )
22680 .unwrap(),
22681 DurableManagedSpawnReplayDecision::Reserved,
22682 ));
22683 {
22684 let _transaction = shared
22685 .state_transaction
22686 .lock()
22687 .unwrap_or_else(|poisoned| poisoned.into_inner());
22688 shared
22689 .link_durable_managed_spawn_v2_lease_locked(
22690 &request.spawn_spec.idempotency_key,
22691 &lease.lease_id,
22692 )
22693 .unwrap();
22694 shared.persist_state_locked().unwrap();
22695 }
22696 drop(shared);
22697
22698 let mut loaded = session_registry::load(
22699 Some(&state_path),
22700 &NodeId::new("node-terminal-test").unwrap(),
22701 )
22702 .unwrap();
22703 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22704 *reopened
22705 .managed_worktrees
22706 .lock()
22707 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22708 ManagedWorktreeRegistry::from_records(
22709 std::mem::take(&mut loaded.managed_worktrees),
22710 std::mem::take(&mut loaded.managed_worktree_tombstones),
22711 )
22712 .unwrap();
22713 *reopened
22714 .managed_spawn_replays
22715 .lock()
22716 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22717 .managed_spawn_replays
22718 .into_iter()
22719 .map(|record| (record.idempotency_key.clone(), record))
22720 .collect();
22721 let lease_count = reopened
22722 .managed_worktrees
22723 .lock()
22724 .unwrap_or_else(|poisoned| poisoned.into_inner())
22725 .records()
22726 .len();
22727
22728 let failure = reopened
22729 .spawn_managed_worktree_v2(request)
22730 .await
22731 .unwrap_err();
22732
22733 assert_eq!(
22734 failure.code,
22735 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22736 );
22737 assert_eq!(
22738 reopened
22739 .managed_worktrees
22740 .lock()
22741 .unwrap_or_else(|poisoned| poisoned.into_inner())
22742 .records()
22743 .len(),
22744 lease_count,
22745 );
22746 std::fs::remove_dir_all(root).unwrap();
22747 }
22748
22749 #[tokio::test]
22750 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22751 let root = temporary_workspace_root("managed-v2-reopen");
22752 let allocation = root.join("allocation");
22753 std::fs::create_dir_all(&allocation).unwrap();
22754 let state_path = root.join("node-state.json");
22755 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22756 let lease = install_managed_lease(&shared);
22757 let request = managed_v2_request("managed-v2-reopen");
22758 let session = SessionAddress {
22759 workspace_id: lease.workspace_id.clone(),
22760 session: SessionKey {
22761 instance_id: AgentInstanceId(91),
22762 generation: SessionGeneration(1),
22763 },
22764 };
22765 let snapshot = {
22766 let mut registry = shared
22767 .managed_worktrees
22768 .lock()
22769 .unwrap_or_else(|poisoned| poisoned.into_inner());
22770 let current = registry.get_mut(&lease.lease_id).unwrap();
22771 current.state = ManagedWorktreeLeaseState::InUse;
22772 current.session_holders.push(ManagedWorktreeSessionHolder {
22773 incarnation_id: shared.incarnation_id,
22774 instance_id: session.session.instance_id,
22775 generation: session.session.generation,
22776 });
22777 current.snapshot()
22778 };
22779 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22780 let receipt = managed_spawn_receipt(
22781 &resolved,
22782 shared.incarnation_id,
22783 session,
22784 snapshot,
22785 None,
22786 None,
22787 None,
22788 );
22789 assert!(matches!(
22790 shared
22791 .acquire_durable_managed_spawn_v2(
22792 &request,
22793 managed_spawn_request_digest_v2(&request).unwrap(),
22794 )
22795 .unwrap(),
22796 DurableManagedSpawnReplayDecision::Reserved,
22797 ));
22798 {
22799 let _transaction = shared
22800 .state_transaction
22801 .lock()
22802 .unwrap_or_else(|poisoned| poisoned.into_inner());
22803 shared
22804 .link_durable_managed_spawn_v2_lease_locked(
22805 &request.spawn_spec.idempotency_key,
22806 &lease.lease_id,
22807 )
22808 .unwrap();
22809 shared.persist_state_locked().unwrap();
22810 }
22811 shared
22812 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22813 .unwrap();
22814 let legacy_same_key = ManagedWorktreeSpawnRequest {
22815 spawn_spec: request.spawn_spec.clone(),
22816 worktree_profile_id: request.worktree_profile_id.clone(),
22817 };
22818 shared.remember_managed_spawn_attempt(
22819 Some(&request.spawn_spec.idempotency_key),
22820 legacy_same_key.clone(),
22821 Ok(receipt.clone()),
22822 Instant::now(),
22823 );
22824 assert!(shared
22825 .replay_managed_spawn(&legacy_same_key, Instant::now())
22826 .unwrap()
22827 .is_none());
22828 drop(shared);
22829
22830 let mut loaded = session_registry::load(
22831 Some(&state_path),
22832 &NodeId::new("node-terminal-test").unwrap(),
22833 )
22834 .unwrap();
22835 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22836 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22837 *reopened
22838 .managed_worktrees
22839 .lock()
22840 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22841 ManagedWorktreeRegistry::from_records(
22842 std::mem::take(&mut loaded.managed_worktrees),
22843 std::mem::take(&mut loaded.managed_worktree_tombstones),
22844 )
22845 .unwrap();
22846 *reopened
22847 .managed_spawn_replays
22848 .lock()
22849 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22850 .managed_spawn_replays
22851 .into_iter()
22852 .map(|record| (record.idempotency_key.clone(), record))
22853 .collect();
22854 let lease_count_before = reopened
22855 .managed_worktrees
22856 .lock()
22857 .unwrap_or_else(|poisoned| poisoned.into_inner())
22858 .records()
22859 .len();
22860
22861 assert_eq!(
22862 reopened
22863 .spawn_managed_worktree_v2(request.clone())
22864 .await
22865 .unwrap(),
22866 receipt,
22867 );
22868 assert_eq!(
22869 reopened
22870 .managed_worktrees
22871 .lock()
22872 .unwrap_or_else(|poisoned| poisoned.into_inner())
22873 .records()
22874 .len(),
22875 lease_count_before,
22876 );
22877 let mut changed = request;
22878 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22879 let failure = reopened
22880 .spawn_managed_worktree_v2(changed)
22881 .await
22882 .unwrap_err();
22883 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22884 assert_eq!(
22885 reopened
22886 .managed_worktrees
22887 .lock()
22888 .unwrap_or_else(|poisoned| poisoned.into_inner())
22889 .records()
22890 .len(),
22891 lease_count_before,
22892 );
22893 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22894 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22895 let failure = reopened
22896 .spawn_managed_worktree_v2(new_key)
22897 .await
22898 .unwrap_err();
22899 assert_eq!(
22900 failure.code,
22901 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22902 );
22903 assert_eq!(
22904 reopened
22905 .managed_worktrees
22906 .lock()
22907 .unwrap_or_else(|poisoned| poisoned.into_inner())
22908 .records()
22909 .len(),
22910 lease_count_before,
22911 );
22912 assert_eq!(
22913 reopened
22914 .managed_spawn_replays
22915 .lock()
22916 .unwrap_or_else(|poisoned| poisoned.into_inner())
22917 .len(),
22918 1,
22919 );
22920 std::fs::remove_dir_all(root).unwrap();
22921 }
22922
22923 #[test]
22924 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22925 let shared = terminal_test_shared();
22926 let request = ManagedWorktreeSpawnRequest {
22927 spawn_spec: spawn_spec_fixture(),
22928 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22929 };
22930 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22931 shared.remember_managed_spawn(
22932 request.clone(),
22933 Err(expected.clone()),
22934 Instant::now(),
22935 );
22936
22937 assert_eq!(
22938 shared
22939 .replay_managed_spawn(&request, Instant::now())
22940 .unwrap()
22941 .unwrap(),
22942 Err(expected),
22943 );
22944 assert!(shared
22945 .managed_spawn_replays
22946 .lock()
22947 .unwrap_or_else(|poisoned| poisoned.into_inner())
22948 .is_empty());
22949 }
22950
22951 #[tokio::test]
22952 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22953 let shared = terminal_test_shared();
22954 let lease = install_managed_lease(&shared);
22955 let raw = shared.spawn_session(
22956 lease.workspace_id.clone(),
22957 agent("claude"),
22958 SessionMode::Pty,
22959 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22960 None,
22961 ).await.unwrap_err();
22962 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22963 let mut spec = spawn_spec_fixture();
22964 spec.target.workspace_id = lease.workspace_id.clone();
22965 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22966 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22967 let mut selected = spawn_spec_fixture();
22968 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22969 selected.target.worktree_id = Some(lease.workspace_id);
22970 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22971 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22972 assert!(shared.handle.snapshot().sessions.is_empty());
22973 }
22974
22975 #[test]
22976 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22977 let shared = terminal_test_shared();
22978 let lease = install_managed_lease(&shared);
22979 let snapshot = snapshot_for_wire(
22980 &shared, true, true, true, false, true, true, false, false, true,
22981 );
22982 assert!(snapshot.managed_worktrees.is_empty());
22983 assert!(snapshot.workspaces.iter().all(|workspace| {
22984 workspace.managed_worktree_profiles.is_none()
22985 }));
22986 let mut reply = ResponseEnvelope {
22987 request_id: 1,
22988 result: Ok(NodeResponse::Resync {
22989 event_sequence: 1,
22990 oldest_available_sequence: 1,
22991 snapshot: shared.snapshot(),
22992 events: vec![NodeEventEnvelope {
22993 sequence: 1,
22994 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22995 }],
22996 }),
22997 };
22998 project_response_without_managed_worktrees(&mut reply);
22999 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23000 panic!("projection changed the response kind");
23001 };
23002 assert!(snapshot.managed_worktrees.is_empty());
23003 assert!(events.is_empty());
23004 let mut git = git_snapshot_for_parser();
23005 git.branch = Some(lease.branch.clone());
23006 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23007 lease_id: lease.lease_id.clone(),
23008 source_workspace_id: lease.source_workspace_id.clone(),
23009 branch: lease.branch.clone(),
23010 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23011 active_session_count: 1,
23012 managed_record_count: 0,
23013 });
23014 let mut inspection_reply = ResponseEnvelope {
23015 request_id: 2,
23016 result: Ok(NodeResponse::WorkspaceInspected {
23017 inspection: WorkspaceInspection {
23018 workspace_id: lease.workspace_id.clone(),
23019 entries: Vec::new(),
23020 tree_truncated: false,
23021 git,
23022 truncation: None,
23023 },
23024 }),
23025 };
23026 project_response_without_managed_worktrees(&mut inspection_reply);
23027 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23028 panic!("projection changed the inspection response kind");
23029 };
23030 assert!(inspection.git.managed_worktree.is_none());
23031 let failure = managed_git_worktree_failure(GitWorktreeError {
23032 kind: GitWorktreeErrorKind::Failed,
23033 message: r"git failed at C:\private\managed-target".to_owned(),
23034 });
23035 assert!(!failure.message.contains("private"));
23036 assert!(!failure.message.contains("managed-target"));
23037 }
23038
23039 #[tokio::test]
23040 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23041 let root = temporary_workspace_root("managed-restart");
23042 let source = root.join("source");
23043 let allocation = root.join("allocation");
23044 std::fs::create_dir_all(&source).unwrap();
23045 std::fs::create_dir_all(&allocation).unwrap();
23046 run_test_git(&source, &["init"]);
23047 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23048 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23049 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23050 run_test_git(&source, &["add", "seed.txt"]);
23051 run_test_git(&source, &["commit", "-m", "seed"]);
23052 let profile = ManagedWorktreeProfile::new(
23053 WorktreeProfileId::new("default").unwrap(),
23054 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23055 &allocation,
23056 "gate4agent",
23057 "HEAD",
23058 ManagedWorktreeRetention::Retain,
23059 ).unwrap();
23060 let workspace = WorkspaceConfig::new(
23061 WorkspaceId::new("primary").unwrap(),
23062 &source,
23063 ).unwrap()
23064 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23065 .with_managed_worktree_profile(profile.clone()).unwrap();
23066 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23067 let source_text = workspace.canonical_root().to_owned();
23068 let target_text = target.to_string_lossy().into_owned();
23069 let created = create_git_worktree(
23070 &source_text,
23071 &target_text,
23072 "gate4agent/mw-restart",
23073 Some("HEAD"),
23074 ).await.unwrap();
23075 let lease = ManagedWorktreeLeaseRecord {
23076 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23077 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23078 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23079 profile_id: profile.profile_id().clone(),
23080 profile_revision: profile.revision().clone(),
23081 target_root: created.path.clone(),
23082 branch: "gate4agent/mw-restart".to_owned(),
23083 base_commit: created.head.clone(),
23084 expected_head: None,
23085 retention: ManagedWorktreeRetention::Retain,
23086 state: ManagedWorktreeLeaseState::Allocating,
23087 session_holders: Vec::new(),
23088 record_holders: Vec::new(),
23089 cleanup_failure: None,
23090 created_at_unix_ms: 1,
23091 updated_at_unix_ms: 1,
23092 };
23093 let catalog = active_registry().unwrap();
23094 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23095 let shared = NodeShared::new_with_incarnation(
23096 handle,
23097 "fixture-token".to_owned(),
23098 NodeId::new("node-managed-restart").unwrap(),
23099 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23100 vec![workspace],
23101 vec![agent("claude")],
23102 ProviderRuntimeStatuses::default(),
23103 None,
23104 Vec::new(),
23105 Vec::new(),
23106 SpawnProfileRegistry::default(),
23107 None,
23108 None,
23109 None,
23110 None,
23111 Vec::new(),
23112 vec![lease.clone()],
23113 Vec::new(),
23114 Vec::new(),
23115 None,
23116 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23117 );
23118 shared.reconcile_managed_worktrees().await;
23119 let retained = shared.managed_worktrees.lock()
23120 .unwrap_or_else(|poisoned| poisoned.into_inner())
23121 .get(&lease.lease_id).unwrap().clone();
23122 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23123 assert!(shared.workspaces.read()
23124 .unwrap_or_else(|poisoned| poisoned.into_inner())
23125 .contains_key(&lease.workspace_id));
23126 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23127 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23128 assert!(!Path::new(&created.path).exists());
23129 std::fs::remove_dir_all(root).unwrap();
23130 }
23131
23132 fn run_test_git(root: &Path, arguments: &[&str]) {
23133 let output = std::process::Command::new("git")
23134 .arg("-C").arg(root).args(arguments).output().unwrap();
23135 assert!(
23136 output.status.success(),
23137 "git {:?} failed: {}",
23138 arguments,
23139 String::from_utf8_lossy(&output.stderr),
23140 );
23141 }
23142
23143 #[tokio::test]
23144 async fn spawn_spec_validation_fails_before_session_mutation() {
23145 let shared = terminal_test_shared();
23146 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23147 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23148 }));
23149 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23150 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23151 }));
23152 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23153 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23154 }));
23155
23156 let mut target_mismatch = spawn_spec_fixture();
23157 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23158 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23159 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23160
23161 let mut unknown_profile = spawn_spec_fixture();
23162 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23163 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23164 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23165 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23166
23167 let mut revision_mismatch = spawn_spec_fixture();
23168 revision_mismatch.idempotency_key =
23169 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23170 revision_mismatch.expected_profile_revision =
23171 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23172 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23173 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23174
23175 let mut unsupported_materializer = spawn_spec_fixture();
23176 unsupported_materializer.idempotency_key =
23177 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23178 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23179 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23180 };
23181 let failure = shared
23182 .spawn_from_spec(unsupported_materializer)
23183 .await
23184 .unwrap_err();
23185 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23186
23187 let failure = shared
23188 .spawn_session_with_deadline(
23189 WorkspaceId::new("primary").unwrap(),
23190 agent("claude"),
23191 SessionMode::Pty,
23192 gate4agent_types::TerminalSize {
23193 rows: 24,
23194 columns: 80,
23195 },
23196 None,
23197 None,
23198 None,
23199 None,
23200 None,
23201 None,
23202 SpawnRecordPolicy::ProviderIdentityOnly,
23203 Some(Instant::now()),
23204 &[],
23205 None,
23206 ApprovalLevel::default(),
23207 None,
23208 None,
23209 )
23210 .await
23211 .unwrap_err();
23212 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23213 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23214 assert!(shared.handle.snapshot().sessions.is_empty());
23215 }
23216
23217 #[test]
23218 fn worktree_selection_requires_its_separate_negotiated_capability() {
23219 let mut spec = spawn_spec_fixture();
23220 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23221 let request = NodeRequest::SpawnSpec { spec };
23222 let spawn_spec_capability =
23223 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23224 assert!(request_uses_unnegotiated_capability(
23225 &request,
23226 &[spawn_spec_capability.clone()],
23227 ));
23228 let worktree_capability =
23229 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23230 assert!(request_uses_unnegotiated_capability(
23231 &request,
23232 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23233 ));
23234 let profile_revision_capability =
23235 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23236 assert!(request_uses_unnegotiated_capability(
23237 &request,
23238 &[
23239 spawn_spec_capability.clone(),
23240 worktree_capability.clone(),
23241 profile_revision_capability.clone(),
23242 ],
23243 ));
23244 let bundle_materialization_capability =
23251 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23252 assert!(request_uses_unnegotiated_capability(
23253 &request,
23254 &[
23255 spawn_spec_capability.clone(),
23256 worktree_capability.clone(),
23257 profile_revision_capability.clone(),
23258 bundle_materialization_capability.clone(),
23259 ],
23260 ));
23261 let history_context_pack_capability =
23262 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23263 assert!(!request_uses_unnegotiated_capability(
23264 &request,
23265 &[
23266 spawn_spec_capability,
23267 worktree_capability,
23268 profile_revision_capability,
23269 bundle_materialization_capability,
23270 history_context_pack_capability,
23271 ],
23272 ));
23273 }
23274
23275 #[tokio::test]
23276 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23277 let mut shared = terminal_test_shared();
23278 let spec = spawn_spec_fixture();
23279 let receipt = shared
23280 .resolve_spawn_spec(&spec)
23281 .unwrap()
23282 .receipt(shared.incarnation_id, terminal_address(1));
23283 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23284
23285 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23286 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23287 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23288
23289 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23290
23291 let mut revision_conflicting = spec.clone();
23292 revision_conflicting.expected_profile_revision =
23293 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23294 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23295 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23296
23297 let mut stale_cache_miss = spec.clone();
23298 stale_cache_miss.idempotency_key =
23299 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23300 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23301 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23302
23303 let mut conflicting = spec;
23304 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23305 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23306 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23307 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23308 assert!(shared.handle.snapshot().sessions.is_empty());
23309 }
23310
23311 #[tokio::test]
23312 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23313 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23314 let mut spec = spawn_spec_fixture();
23315 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23316 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23317 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23318
23319 let failure = shared
23320 .create_worktree(
23321 WorkspaceId::new("primary").unwrap(),
23322 WorkspaceId::new("new-worktree").unwrap(),
23323 "not-an-absolute-path".to_owned(),
23324 "invalid branch".to_owned(),
23325 None,
23326 )
23327 .await
23328 .unwrap_err();
23329 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23330
23331 let failure = shared
23332 .remove_worktree(
23333 WorkspaceId::new("primary").unwrap(),
23334 "not-an-absolute-path".to_owned(),
23335 )
23336 .await
23337 .unwrap_err();
23338 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23339 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23340 assert!(shared.handle.snapshot().sessions.is_empty());
23341 }
23342
23343 #[tokio::test]
23344 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23345 let root = temporary_workspace_root("manual-worktree-compensation");
23346 let source = root.join("source");
23347 let target = root.join("target");
23348 let invalid_state_parent = root.join("state-parent-is-a-file");
23349 let invalid_state_path = invalid_state_parent.join("state.json");
23350 std::fs::create_dir_all(&source).unwrap();
23351 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23352 run_test_git(&source, &["init"]);
23353 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23354 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23355 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23356 run_test_git(&source, &["add", "seed.txt"]);
23357 run_test_git(&source, &["commit", "-m", "seed"]);
23358
23359 let catalog = active_registry().unwrap();
23360 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23361 let workspace = WorkspaceConfig::new(
23362 WorkspaceId::new("primary").unwrap(),
23363 &source,
23364 ).unwrap();
23365 let mut shared = NodeShared::new(
23366 handle,
23367 "fixture-token".to_owned(),
23368 NodeId::new("node-worktree-compensation").unwrap(),
23369 vec![workspace],
23370 vec![agent("claude")],
23371 );
23372 shared.state_path = Some(invalid_state_path);
23373
23374 let error = shared.create_worktree(
23375 WorkspaceId::new("primary").unwrap(),
23376 WorkspaceId::new("compensated").unwrap(),
23377 target.to_string_lossy().into_owned(),
23378 "gate4agent/compensated".to_owned(),
23379 Some("HEAD".to_owned()),
23380 ).await.unwrap_err();
23381
23382 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23383 assert!(!target.exists());
23384 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23385 .iter()
23386 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23387 std::fs::remove_dir_all(root).unwrap();
23388 }
23389
23390 #[test]
23391 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23392 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23393 NativeGitWorktreeSnapshot {
23394 path: path.to_owned(),
23395 head: "0123456789abcdef".to_owned(),
23396 branch: Some("refs/heads/test".to_owned()),
23397 is_bare,
23398 is_main,
23399 locked: false,
23400 lock_reason: None,
23401 prunable: false,
23402 prunable_reason: None,
23403 workspace_id: None,
23404 }
23405 }
23406
23407 let source = r"C:\repo";
23408 let selected = r"C:\repo-worktree";
23409 let listed = vec![
23410 worktree(source, true, false),
23411 worktree(selected, false, false),
23412 ];
23413 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23414
23415 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23416 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23417
23418 let bare = vec![
23419 worktree(source, true, false),
23420 worktree(selected, false, true),
23421 ];
23422 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23423 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23424
23425 let foreign_failure =
23426 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23427 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23428 }
23429
23430 fn terminal_frame(sequence: u64) -> TerminalFrame {
23431 TerminalFrame {
23432 sequence,
23433 size: gate4agent_types::TerminalSize {
23434 rows: 24,
23435 columns: 80,
23436 },
23437 cursor_row: 2,
23438 cursor_column: 3,
23439 contents: format!("frame-{sequence}"),
23440 formatted: vec![1, 2, 3],
23441 scrollback_formatted: Vec::new(),
23442 alternate_screen: false,
23443 mouse_protocol_enabled: false,
23444 mouse_protocol_encoding: Default::default(),
23445 produced_at_unix_ms: 0,
23446 screen_state: gate4agent_types::PtyScreenState::default(),
23447 bracketed_paste: None,
23448 }
23449 }
23450
23451 #[test]
23452 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23453 let shared = terminal_test_shared();
23454 let mut receiver = shared.terminal_event_tx.subscribe();
23455 let original = terminal_address(1);
23456
23457 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23458 let first = receiver.try_recv().unwrap();
23459 assert_eq!(first.len(), 1);
23460 assert!(matches!(
23461 &first[0].event,
23462 NodeEvent::TerminalFrame { address, frame }
23463 if address == &original && frame.sequence == 7
23464 ));
23465
23466 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23467 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23468 assert!(matches!(
23469 receiver.try_recv(),
23470 Err(broadcast::error::TryRecvError::Empty)
23471 ));
23472
23473 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23474 let advanced = receiver.try_recv().unwrap();
23475 assert_eq!(advanced[0].sequence, 2);
23476
23477 let resumed = terminal_address(2);
23478 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23479 let reset = receiver.try_recv().unwrap();
23480 assert_eq!(reset[0].sequence, 3);
23481 assert!(matches!(
23482 &reset[0].event,
23483 NodeEvent::TerminalFrame { address, frame }
23484 if address == &resumed && frame.sequence == 1
23485 ));
23486 shared.clear_terminal_frame_watermark(&resumed);
23487 assert!(!shared
23488 .terminal_frame_watermarks
23489 .lock()
23490 .unwrap_or_else(|poisoned| poisoned.into_inner())
23491 .contains_key(&resumed.session.instance_id));
23492 }
23493
23494 #[test]
23495 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23496 let shared = terminal_test_shared();
23497 let mut receiver = shared.terminal_event_tx.subscribe();
23498 let address = terminal_address(1);
23499 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23500
23501 for sequence in 1..=count {
23502 shared.publish_terminal_frame_candidates(vec![(
23503 address.clone(),
23504 terminal_frame(sequence),
23505 )]);
23506 }
23507
23508 let history = shared
23509 .history
23510 .lock()
23511 .unwrap_or_else(|poisoned| poisoned.into_inner());
23512 assert!(history.events.is_empty());
23513 assert_eq!(history.last_sequence, count);
23514 assert_eq!(history.replay_floor_sequence, 1);
23515 drop(history);
23516 let NodeResponse::Resync {
23517 event_sequence,
23518 oldest_available_sequence,
23519 events,
23520 ..
23521 } = shared.resync(0) else {
23522 unreachable!("resync helper returned another response")
23523 };
23524 assert_eq!(event_sequence, count);
23525 assert_eq!(oldest_available_sequence, 1);
23526 assert!(events.is_empty());
23527 assert!(matches!(
23528 resync_required_event(&shared).event,
23529 NodeEvent::ResyncRequired {
23530 oldest_available_sequence: 1,
23531 }
23532 ));
23533 assert!(matches!(
23534 receiver.try_recv(),
23535 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23536 ));
23537 let latest = receiver.try_recv().unwrap();
23538 assert_eq!(latest.len(), 1);
23539 assert_eq!(latest[0].sequence, count);
23540 }
23541
23542 #[test]
23543 fn real_durable_eviction_advances_authoritative_replay_floor() {
23544 let shared = terminal_test_shared();
23545 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23546 shared.publish(NodeEvent::ControllerChanged { controller: None });
23547 }
23548
23549 let NodeResponse::Resync {
23550 event_sequence,
23551 oldest_available_sequence,
23552 events,
23553 ..
23554 } = shared.resync(0) else {
23555 unreachable!("resync helper returned another response")
23556 };
23557 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23558 assert_eq!(oldest_available_sequence, 2);
23559 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23560 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23561 assert!(matches!(
23562 resync_required_event(&shared).event,
23563 NodeEvent::ResyncRequired {
23564 oldest_available_sequence: 2,
23565 }
23566 ));
23567 }
23568
23569 #[test]
23570 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23571 let shared = terminal_test_shared();
23572 let without_capability = terminal_event_subscription(&shared, false);
23573 let with_capability = terminal_event_subscription(&shared, true);
23574 assert!(without_capability.is_none());
23575 assert!(with_capability.is_some());
23576 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23577 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23578 }));
23579 }
23580
23581 #[test]
23582 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23583 let mut pending = Vec::new();
23584 let mut event_sequence = 0_u64;
23585 for frame_sequence in 1..=4 {
23586 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23587 event_sequence += 1;
23588 let mut address = terminal_address(1);
23589 address.session.instance_id = AgentInstanceId(instance);
23590 queue_connection_event(
23591 &mut pending,
23592 NodeEventEnvelope {
23593 sequence: event_sequence,
23594 event: NodeEvent::TerminalFrame {
23595 address,
23596 frame: terminal_frame(frame_sequence),
23597 },
23598 },
23599 0,
23600 );
23601 }
23602 }
23603 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23604 pending.sort_unstable_by_key(|event| event.sequence);
23605
23606 let mut uninterrupted = pending.clone();
23607 let first_burst = connection_event_burst_len(uninterrupted.len());
23608 uninterrupted.drain(..first_burst);
23609 let second_burst = connection_event_burst_len(uninterrupted.len());
23610 uninterrupted.drain(..second_burst);
23611 assert!(uninterrupted.is_empty());
23612
23613 let (frame_tx, mut frame_rx) =
23614 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23615 frame_tx
23616 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23617 request_id: 77,
23618 request: NodeRequest::Snapshot,
23619 })))
23620 .unwrap();
23621 let burst_len = connection_event_burst_len(pending.len());
23622 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23623 pending.drain(..burst_len);
23624 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23625 assert!(matches!(
23626 frame_rx.try_recv(),
23627 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23628 request_id: 77,
23629 request: NodeRequest::Snapshot,
23630 })))
23631 ));
23632 }
23633
23634 #[test]
23635 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23636 let mut pending = Vec::new();
23637 for sequence in [7_u64, 8] {
23638 let mut address = terminal_address(1);
23639 address.session.instance_id = AgentInstanceId(sequence);
23640 queue_connection_event(
23641 &mut pending,
23642 NodeEventEnvelope {
23643 sequence,
23644 event: NodeEvent::TerminalFrame {
23645 address,
23646 frame: terminal_frame(sequence),
23647 },
23648 },
23649 0,
23650 );
23651 }
23652 let mut discard_through = 0;
23653 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23654 assert!(pending.is_empty());
23655
23656 let replayed = NodeEventEnvelope {
23657 sequence: 8,
23658 event: NodeEvent::TerminalFrame {
23659 address: terminal_address(1),
23660 frame: terminal_frame(8),
23661 },
23662 };
23663 let advanced = NodeEventEnvelope {
23664 sequence: 9,
23665 event: NodeEvent::TerminalFrame {
23666 address: terminal_address(2),
23667 frame: terminal_frame(1),
23668 },
23669 };
23670 queue_connection_event_batch(
23671 &mut pending,
23672 &[replayed, advanced.clone()],
23673 discard_through,
23674 );
23675 assert_eq!(pending, vec![advanced]);
23676 }
23677
23678 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23679 ManagedSessionRecord {
23680 record_id: SessionRecordId::new(record_id).unwrap(),
23681 display_name: format!("{provider} record"),
23682 provider: agent(provider),
23683 mode: SessionMode::Pty,
23684 state: ManagedSessionState::Unavailable,
23685 workspace_id: WorkspaceId::new("primary").unwrap(),
23686 canonical_root: opaque_windows_path(
23687 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23688 ),
23689 provider_session: None,
23690 active_session: None,
23691 environment_profile: None,
23692 bundle: None,
23693 context_id: None,
23694 context: None,
23695 exported_context: None,
23696 task_binding: None,
23697 created_at_unix_ms: 1,
23698 updated_at_unix_ms: 1,
23699 last_error: None,
23700 }
23701 }
23702
23703 fn retention_test_record(
23704 workspace_id: &str,
23705 record_id: &str,
23706 state: ManagedSessionState,
23707 updated_at_unix_ms: u64,
23708 ) -> ManagedSessionRecord {
23709 ManagedSessionRecord {
23710 record_id: SessionRecordId::new(record_id).unwrap(),
23711 display_name: format!("{record_id} display"),
23712 provider: agent("claude"),
23713 mode: SessionMode::Pty,
23714 state,
23715 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23716 canonical_root: opaque_windows_path(
23717 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23718 ),
23719 provider_session: None,
23720 active_session: None,
23721 environment_profile: None,
23722 bundle: None,
23723 context_id: None,
23724 context: None,
23725 exported_context: None,
23726 task_binding: None,
23727 created_at_unix_ms: updated_at_unix_ms,
23728 updated_at_unix_ms,
23729 last_error: None,
23730 }
23731 }
23732
23733 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23741 vec![
23742 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23743 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23744 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23745 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23746 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23747 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23748 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23749 retention_test_record(
23750 "workspace-a",
23751 "decoy-pending",
23752 ManagedSessionState::IdentityPending,
23753 now - 500_000,
23754 ),
23755 retention_test_record(
23756 "workspace-a",
23757 "decoy-dormant",
23758 ManagedSessionState::Dormant,
23759 now - 500_000,
23760 ),
23761 ]
23762 }
23763
23764 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23765 let root = temporary_workspace_root("session-record-retention");
23766 std::fs::create_dir_all(&root).unwrap();
23767 let catalog = active_registry().unwrap();
23768 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23769 let workspace_a =
23770 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23771 let workspace_b =
23772 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23773 let mut shared = NodeShared::new(
23774 handle,
23775 "fixture-token".to_owned(),
23776 NodeId::new("node-retention-test").unwrap(),
23777 vec![workspace_a, workspace_b],
23778 vec![agent("claude")],
23779 );
23780 shared.state_path = Some(root.join("node-state-v10.json"));
23781 let now = unix_time_ms();
23782 for record in retention_fixture_records(now) {
23783 shared.insert_record(record).unwrap();
23784 }
23785 shared
23786 .persist_state_locked()
23787 .expect("fixture-only, empty-history persist against a fresh temp state path");
23788 (shared, runtime, root, now)
23789 }
23790
23791 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23792 shared
23793 .history
23794 .lock()
23795 .unwrap_or_else(|poisoned| poisoned.into_inner())
23796 .events
23797 .iter()
23798 .filter_map(|envelope| match &envelope.event {
23799 NodeEvent::SessionRecordRemoved { record_id } => {
23800 Some(record_id.as_str().to_owned())
23801 }
23802 _ => None,
23803 })
23804 .collect()
23805 }
23806
23807 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23808 shared
23809 .session_records
23810 .lock()
23811 .unwrap_or_else(|poisoned| poisoned.into_inner())
23812 .records
23813 .keys()
23814 .map(|record_id| record_id.as_str().to_owned())
23815 .collect()
23816 }
23817
23818 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23819 values.iter().map(|value| (*value).to_owned()).collect()
23820 }
23821
23822 #[tokio::test]
23823 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23824 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23825 shared.session_record_retention = SessionRecordRetentionConfig {
23826 age_ms: 0,
23827 keep_per_workspace: 1,
23828 };
23829 shared.retire_unavailable_session_records().await;
23830
23831 assert_eq!(
23832 retention_remaining_record_ids(&shared),
23833 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23834 );
23835 assert_eq!(
23836 retention_removed_record_ids(&shared),
23837 owned(&["a1", "a2", "b1", "b2"]),
23838 );
23839 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23840
23841 drop(shared);
23842 drop(runtime);
23843 std::fs::remove_dir_all(root).unwrap();
23844 }
23845
23846 #[tokio::test]
23852 async fn session_record_retention_both_set_retires_only_the_intersection() {
23853 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23854 shared.session_record_retention = SessionRecordRetentionConfig {
23855 age_ms: 250_000,
23856 keep_per_workspace: 1,
23857 };
23858 shared.retire_unavailable_session_records().await;
23859
23860 assert_eq!(
23861 retention_remaining_record_ids(&shared),
23862 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23863 );
23864 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23865 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23866
23867 drop(shared);
23868 drop(runtime);
23869 std::fs::remove_dir_all(root).unwrap();
23870 }
23871
23872 #[tokio::test]
23873 async fn session_record_retention_both_disabled_is_a_no_op() {
23874 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23875 shared.session_record_retention = SessionRecordRetentionConfig::default();
23876 shared.retire_unavailable_session_records().await;
23877
23878 assert_eq!(
23879 retention_remaining_record_ids(&shared),
23880 owned(&[
23881 "a1", "a2", "a3", "b1", "b2", "b3",
23882 "decoy-live", "decoy-pending", "decoy-dormant",
23883 ]),
23884 );
23885 assert!(retention_removed_record_ids(&shared).is_empty());
23886 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23887
23888 drop(shared);
23889 drop(runtime);
23890 std::fs::remove_dir_all(root).unwrap();
23891 }
23892
23893 #[test]
23894 fn session_record_context_export_selects_one_exact_history_identity() {
23895 let candidates = vec![
23896 HistoryCandidateSummary {
23897 id: "candidate-other".to_owned(),
23898 session_id_hint: "provider-other".to_owned(),
23899 modified_at_unix_ms: Some(1),
23900 },
23901 HistoryCandidateSummary {
23902 id: "candidate-exact".to_owned(),
23903 session_id_hint: "provider-exact".to_owned(),
23904 modified_at_unix_ms: Some(2),
23905 },
23906 ];
23907 assert_eq!(
23908 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23909 "candidate-exact",
23910 );
23911 }
23912
23913 #[test]
23914 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23915 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23916 id: id.to_owned(),
23917 session_id_hint: hint.to_owned(),
23918 modified_at_unix_ms: None,
23919 };
23920 let missing = select_history_candidate(
23921 &[candidate("candidate-other", "provider-other")],
23922 Some("provider-exact"),
23923 )
23924 .unwrap_err();
23925 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23926 let ambiguous = select_history_candidate(
23927 &[
23928 candidate("candidate-a", "provider-exact"),
23929 candidate("candidate-b", "provider-exact"),
23930 ],
23931 Some("provider-exact"),
23932 )
23933 .unwrap_err();
23934 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23935 }
23936
23937 #[test]
23945 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23946 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23947 id: id.to_owned(),
23948 session_id_hint: hint.to_owned(),
23949 modified_at_unix_ms: None,
23950 };
23951 assert_eq!(
23952 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23953 .unwrap(),
23954 "candidate-sole",
23955 );
23956 let missing = select_history_candidate(&[], None).unwrap_err();
23957 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23958 let ambiguous = select_history_candidate(
23959 &[
23960 candidate("candidate-a", "sidecar-session-a"),
23961 candidate("candidate-b", "sidecar-session-b"),
23962 ],
23963 None,
23964 )
23965 .unwrap_err();
23966 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23967 }
23968
23969 #[test]
23970 fn session_record_context_export_revalidation_rejects_record_change() {
23971 let session = terminal_address(1);
23972 let identity = ProviderSessionIdentity {
23973 key: ProviderSessionKey::SessionId,
23974 id: "provider-exact".to_owned(),
23975 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23976 };
23977 let mut expected = record("claude", "record-context-export");
23978 expected.state = ManagedSessionState::Live;
23979 expected.provider_session = Some(identity.clone());
23980 expected.active_session = Some(session.clone());
23981 let binding = SessionBinding {
23982 workspace_id: session.workspace_id.clone(),
23983 generation: session.session.generation,
23984 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23985 pending_resume: None,
23986 record_id: Some(expected.record_id.clone()),
23987 managed_worktree_lease_id: None,
23988 environment_profile: None,
23989 bundle: None,
23990 context: None,
23991 materialization_id: None,
23992 };
23993 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23994 assert!(session_record_context_export_binding_is_exact(
23995 &expected,
23996 &expected,
23997 Some(&identity),
23998 &session,
23999 &binding,
24000 &expected.provider,
24001 ¤t_root,
24002 false,
24003 ));
24004 let mut changed = expected.clone();
24005 changed.updated_at_unix_ms += 1;
24006 assert!(!session_record_context_export_binding_is_exact(
24007 &expected,
24008 &changed,
24009 Some(&identity),
24010 &session,
24011 &binding,
24012 &expected.provider,
24013 ¤t_root,
24014 false,
24015 ));
24016 assert!(!session_record_context_export_binding_is_exact(
24021 &expected,
24022 &changed,
24023 Some(&identity),
24024 &session,
24025 &binding,
24026 &expected.provider,
24027 ¤t_root,
24028 true,
24029 ));
24030 let mut detached = expected.clone();
24031 detached.state = ManagedSessionState::Dormant;
24032 detached.active_session = None;
24033 detached.updated_at_unix_ms += 1;
24034 assert!(!session_record_context_export_binding_is_exact(
24035 &expected,
24036 &detached,
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(
24045 &expected,
24046 &detached,
24047 Some(&identity),
24048 &session,
24049 &binding,
24050 &expected.provider,
24051 ¤t_root,
24052 true,
24053 ));
24054 }
24055
24056 #[test]
24063 fn session_record_context_export_binding_is_exact_without_identity() {
24064 let session = terminal_address(1);
24065 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24066 expected.state = ManagedSessionState::Live;
24067 expected.active_session = Some(session.clone());
24068 let binding = SessionBinding {
24069 workspace_id: session.workspace_id.clone(),
24070 generation: session.session.generation,
24071 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24072 pending_resume: None,
24073 record_id: Some(expected.record_id.clone()),
24074 managed_worktree_lease_id: None,
24075 environment_profile: None,
24076 bundle: None,
24077 context: None,
24078 materialization_id: None,
24079 };
24080 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24081 assert!(session_record_context_export_binding_is_exact(
24082 &expected,
24083 &expected,
24084 None,
24085 &session,
24086 &binding,
24087 &expected.provider,
24088 ¤t_root,
24089 false,
24090 ));
24091 let mut drifted = expected.clone();
24095 drifted.provider_session = Some(ProviderSessionIdentity {
24096 key: ProviderSessionKey::SessionId,
24097 id: "unexpected-identity".to_owned(),
24098 transcript_path: None,
24099 });
24100 assert!(!session_record_context_export_binding_is_exact(
24101 &expected,
24102 &drifted,
24103 None,
24104 &session,
24105 &binding,
24106 &expected.provider,
24107 ¤t_root,
24108 false,
24109 ));
24110 }
24111
24112 #[test]
24113 fn session_record_context_export_preflight_requires_live_usable_source() {
24114 let mut source = record("claude", "record-context-export-preflight");
24115 source.state = ManagedSessionState::Live;
24116 assert!(session_record_context_export_source_is_usable(
24117 &source,
24118 &SessionStatus::Running,
24119 false,
24120 ));
24121 assert!(session_record_context_export_source_is_usable(
24122 &source,
24123 &SessionStatus::Exited { exit_code: Some(0) },
24124 false,
24125 ));
24126
24127 source.state = ManagedSessionState::Dormant;
24128 assert!(!session_record_context_export_source_is_usable(
24129 &source,
24130 &SessionStatus::Running,
24131 false,
24132 ));
24133 assert!(session_record_context_export_source_is_usable(
24137 &source,
24138 &SessionStatus::Exited { exit_code: Some(0) },
24139 true,
24140 ));
24141 assert!(!session_record_context_export_source_is_usable(
24142 &source,
24143 &SessionStatus::Exited { exit_code: Some(0) },
24144 false,
24145 ));
24146 source.state = ManagedSessionState::Live;
24147 assert!(!session_record_context_export_source_is_usable(
24148 &source,
24149 &SessionStatus::Starting,
24150 false,
24151 ));
24152 }
24153
24154 #[test]
24155 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24156 let catalog = active_registry().unwrap();
24157 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24158 let workspace = WorkspaceConfig::new(
24159 WorkspaceId::new("primary").unwrap(),
24160 std::env::current_dir().unwrap(),
24161 )
24162 .unwrap();
24163 let shared = NodeShared::new(
24164 handle,
24165 "fixture-token".to_owned(),
24166 NodeId::new("node-context-export-conflict").unwrap(),
24167 vec![workspace],
24168 vec![agent("claude")],
24169 );
24170 let history = HistorySessionRecord {
24171 session_id: "provider-exact".to_owned(),
24172 title: None,
24173 cwd: None,
24174 model: None,
24175 message_count: 1,
24176 completed_turn_count: None,
24177 total_tokens: 0,
24178 messages: vec![gate4agent_types::HistoryMessageRecord {
24179 role: gate4agent_types::HistoryMessageRole::User,
24180 text: "bounded context".to_owned(),
24181 }],
24182 };
24183 let pack = NodeContextPack::export(
24184 ContextPackLineageReceipt {
24185 source_node_id: shared.node_id.clone(),
24186 source_session: terminal_address(1),
24187 source_provider: agent("claude"),
24188 },
24189 &history,
24190 )
24191 .unwrap();
24192 let context_id = pack.receipt().id.clone();
24193 let session = terminal_address(1);
24194 let identity = ProviderSessionIdentity {
24195 key: ProviderSessionKey::SessionId,
24196 id: history.session_id.clone(),
24197 transcript_path: None,
24198 };
24199 let mut expected = record("claude", "record-context-export-final-conflict");
24200 expected.state = ManagedSessionState::Live;
24201 expected.provider_session = Some(identity.clone());
24202 expected.active_session = Some(session.clone());
24203 let mut changed = expected.clone();
24204 changed.updated_at_unix_ms += 1;
24205 shared.insert_record(changed).unwrap();
24206
24207 let error = shared
24208 .commit_context_pack_for_session_record(
24209 &expected,
24210 Some(&identity),
24211 &session,
24212 "candidate-exact",
24213 &history,
24214 pack,
24215 false,
24216 )
24217 .unwrap_err();
24218
24219 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24220 assert!(shared
24221 .context_catalog
24222 .read()
24223 .unwrap_or_else(|poisoned| poisoned.into_inner())
24224 .get(&context_id)
24225 .is_none());
24226 }
24227
24228 #[tokio::test]
24229 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24230 let root = temporary_workspace_root("session-task-binding");
24231 std::fs::create_dir_all(&root).unwrap();
24232 let state_path = root.join("node-state.json");
24233 let catalog = active_registry().unwrap();
24234 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24235 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24236 let node_id = NodeId::new("node-1").unwrap();
24237 let mut shared = NodeShared::new(
24238 handle,
24239 "fixture-token".to_owned(),
24240 node_id.clone(),
24241 vec![workspace],
24242 vec![agent("claude")],
24243 );
24244 shared.state_path = Some(state_path.clone());
24245 let record = record("claude", "sr-task");
24246 let record_id = record.record_id.clone();
24247 shared.insert_record(record).unwrap();
24248 shared.persist_state().unwrap();
24249 shared
24250 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24251 .unwrap();
24252
24253 let minted = process_request_inner(
24254 &shared,
24255 77,
24256 ClientRole::Operator,
24257 NodeRequest::SetSessionTask {
24258 record_id: record_id.clone(),
24259 expected_revision: 0,
24260 target: SessionTaskTargetV1::New,
24261 },
24262 )
24263 .await
24264 .unwrap();
24265 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24266 panic!("session task mutation returned another response");
24267 };
24268 let minted_binding = minted.task_binding.clone().unwrap();
24269 let minted_task_id = minted_binding.task_id.clone().unwrap();
24270 assert_eq!(minted_binding.revision, 1);
24271 assert!(minted_task_id.as_str().starts_with("task-"));
24272 assert_eq!(minted_task_id.as_str().len(), 29);
24273
24274 let event_count = shared.current_sequence();
24275 let repeated = process_request_inner(
24276 &shared,
24277 77,
24278 ClientRole::Operator,
24279 NodeRequest::SetSessionTask {
24280 record_id: record_id.clone(),
24281 expected_revision: 1,
24282 target: SessionTaskTargetV1::Existing {
24283 task_id: minted_task_id.clone(),
24284 },
24285 },
24286 )
24287 .await
24288 .unwrap();
24289 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24290 assert_eq!(shared.current_sequence(), event_count);
24291
24292 let stale = process_request_inner(
24293 &shared,
24294 77,
24295 ClientRole::Operator,
24296 NodeRequest::SetSessionTask {
24297 record_id: record_id.clone(),
24298 expected_revision: 0,
24299 target: SessionTaskTargetV1::Existing {
24300 task_id: minted_task_id,
24301 },
24302 },
24303 )
24304 .await
24305 .unwrap_err();
24306 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24307
24308 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24309 let assigned = process_request_inner(
24310 &shared,
24311 77,
24312 ClientRole::Operator,
24313 NodeRequest::SetSessionTask {
24314 record_id: record_id.clone(),
24315 expected_revision: 1,
24316 target: SessionTaskTargetV1::Existing {
24317 task_id: assigned_id.clone(),
24318 },
24319 },
24320 )
24321 .await
24322 .unwrap();
24323 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24324 panic!("session task assignment returned another response");
24325 };
24326 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24327 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24328
24329 let cleared = process_request_inner(
24330 &shared,
24331 77,
24332 ClientRole::Operator,
24333 NodeRequest::SetSessionTask {
24334 record_id: record_id.clone(),
24335 expected_revision: 2,
24336 target: SessionTaskTargetV1::Clear,
24337 },
24338 )
24339 .await
24340 .unwrap();
24341 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24342 panic!("session task clear returned another response");
24343 };
24344 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24345 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24346 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24347 assert_eq!(
24348 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24349 cleared.updated_at_unix_ms,
24350 );
24351
24352 let persisted = std::fs::read(&state_path).unwrap();
24353 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24354 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24355 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24356 std::fs::remove_dir_all(root).unwrap();
24357 }
24358
24359 #[test]
24360 fn session_task_capability_strips_snapshots_events_and_replies() {
24361 let mut bound = record("claude", "sr-task-projection");
24362 bound.updated_at_unix_ms = 2;
24363 bound.task_binding = Some(SessionTaskBindingV1 {
24364 revision: 1,
24365 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24366 changed_at_unix_ms: 2,
24367 });
24368 let request = NodeRequest::SetSessionTask {
24369 record_id: bound.record_id.clone(),
24370 expected_revision: 1,
24371 target: SessionTaskTargetV1::Clear,
24372 };
24373 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24374 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24375 }));
24376 assert!(!request_uses_unnegotiated_capability(
24377 &request,
24378 &baseline_capabilities().unwrap(),
24379 ));
24380 assert!(request_uses_unnegotiated_capability(&request, &[]));
24381
24382 let mut snapshot = terminal_test_shared().snapshot();
24383 snapshot.session_records.push(bound.clone());
24384 let mut reply = ResponseEnvelope {
24385 request_id: 1,
24386 result: Ok(NodeResponse::Resync {
24387 event_sequence: 1,
24388 oldest_available_sequence: 1,
24389 snapshot,
24390 events: vec![NodeEventEnvelope {
24391 sequence: 1,
24392 event: NodeEvent::SessionRecordUpserted {
24393 record: bound.clone(),
24394 },
24395 }],
24396 }),
24397 };
24398 project_response_without_session_task_binding(&mut reply);
24399 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24400 panic!("task projection changed the response kind");
24401 };
24402 assert!(snapshot.session_records[0].task_binding.is_none());
24403 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24404 panic!("task projection changed the event kind");
24405 };
24406 assert!(record.task_binding.is_none());
24407
24408 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24409 sequence: 2,
24410 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24411 });
24412 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24413 panic!("task event projection changed the event kind");
24414 };
24415 assert!(record.task_binding.is_none());
24416
24417 let mut direct = ResponseEnvelope {
24418 request_id: 2,
24419 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24420 };
24421 project_response_without_session_task_binding(&mut direct);
24422 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24423 panic!("task reply projection changed the response kind");
24424 };
24425 assert!(record.task_binding.is_none());
24426 }
24427
24428 #[test]
24429 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24430 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24431 let workspace = WorkspaceConfig::new(
24432 WorkspaceId::new("primary").unwrap(),
24433 std::env::current_dir().unwrap(),
24434 )
24435 .unwrap();
24436 let config = NodeServerConfig::new(
24437 DEFAULT_NODE_ENDPOINT,
24438 "fixture-token",
24439 NodeId::new("node-1").unwrap(),
24440 [workspace],
24441 )
24442 .unwrap();
24443 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24444 }
24445
24446 #[cfg(all(feature = "fixture", windows))]
24447 #[test]
24448 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24449 let root = temporary_workspace_root("provider-bundle-argv-hold");
24450 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24451 std::fs::create_dir_all(&root).unwrap();
24452 std::fs::create_dir_all(&outside).unwrap();
24453 let proof_path = root.join("provider.proof");
24454 let release_signal = root.join("release.signal");
24455 let config = NodeServerConfig::new(
24456 format!(
24457 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24458 std::process::id(),
24459 ),
24460 "fixture-token",
24461 NodeId::new("provider-bundle-hold-node").unwrap(),
24462 [WorkspaceConfig::new(
24463 WorkspaceId::new("primary").unwrap(),
24464 std::env::current_dir().unwrap(),
24465 )
24466 .unwrap()],
24467 )
24468 .unwrap()
24469 .with_spawn_profiles(SpawnProfileRegistry::new([
24470 crate::protocol::SpawnProfileDefaults {
24471 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24472 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24473 .unwrap(),
24474 provider: agent("kimi"),
24475 mode: SessionMode::Pty,
24476 terminal_size: gate4agent_types::TerminalSize {
24477 rows: 24,
24478 columns: 80,
24479 },
24480 prompt: None,
24481 bundle_id: None,
24482 context_id: None,
24483 environment_profile_id: None,
24484 },
24485 ])
24486 .unwrap());
24487 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24488 config,
24489 agent("kimi"),
24490 proof_path.clone(),
24491 release_signal.clone(),
24492 )
24493 .unwrap();
24494 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24495
24496 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24497 agent("kimi"),
24498 proof_path.clone(),
24499 Some(release_signal.clone()),
24500 )
24501 .unwrap();
24502 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24503 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24504 let release_wait = wrapper
24505 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24506 .unwrap();
24507 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24508 assert!(proof_write < release_wait);
24509 assert!(release_wait < fixture_ready);
24510 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24511 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24512 assert_eq!(
24513 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24514 &[
24515 proof_path.to_string_lossy().into_owned(),
24516 release_signal.to_string_lossy().into_owned(),
24517 ],
24518 );
24519
24520 let error = NodeServer::provider_bundle_argv_fixture_spec(
24521 agent("kimi"),
24522 proof_path,
24523 Some(outside.join("release.signal")),
24524 )
24525 .unwrap_err();
24526 assert!(matches!(
24527 error,
24528 NodeServerError::Registry(message)
24529 if message == "provider bundle argv release path must share the proof parent"
24530 ));
24531 drop(server);
24532 std::fs::remove_dir_all(root).unwrap();
24533 std::fs::remove_dir_all(outside).unwrap();
24534 }
24535
24536 #[cfg(feature = "fixture")]
24537 #[test]
24538 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24539 let root = temporary_workspace_root("context-fixture-registry");
24540 std::fs::create_dir_all(&root).unwrap();
24541 let proof_path = root.join("context.proof");
24542 let config = NodeServerConfig::new(
24543 format!(
24544 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24545 std::process::id(),
24546 ),
24547 "fixture-token",
24548 NodeId::new("context-fixture-node").unwrap(),
24549 [WorkspaceConfig::new(
24550 WorkspaceId::new("primary").unwrap(),
24551 std::env::current_dir().unwrap(),
24552 )
24553 .unwrap()],
24554 )
24555 .unwrap();
24556 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24557 let mut enabled = server
24558 .shared
24559 .enabled_providers
24560 .iter()
24561 .map(AgentId::as_str)
24562 .collect::<Vec<_>>();
24563 enabled.sort_unstable();
24564 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24565 for provider in &server.shared.enabled_providers {
24566 assert!(server
24567 .shared
24568 .provider_adapter_contracts
24569 .iter()
24570 .any(|contract| {
24571 &contract.provider == provider
24572 && contract.family == AdapterFamily::History
24573 }));
24574 }
24575 drop(server);
24576 std::fs::remove_dir_all(root).unwrap();
24577 }
24578
24579 #[cfg(feature = "fixture")]
24580 #[test]
24581 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24582 let root = temporary_workspace_root("monitoring-context-fixture");
24583 std::fs::create_dir_all(&root).unwrap();
24584 let proof_path = root.join("context.proof");
24585 let standard = NodeServer::context_pack_fixture_catalog(
24586 proof_path.clone(),
24587 None,
24588 false,
24589 ).unwrap();
24590 let monitoring = NodeServer::context_pack_fixture_catalog(
24591 proof_path.clone(),
24592 None,
24593 true,
24594 ).unwrap();
24595 let claude = AgentId::new("claude").unwrap();
24596 let standard_claude = standard.get(&claude).unwrap();
24597 let monitoring_claude = monitoring.get(&claude).unwrap();
24598 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24602 assert_eq!(
24603 monitoring_claude.capabilities.adapters.history,
24604 standard_claude.capabilities.adapters.history,
24605 );
24606 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24607 let codex = AgentId::new("codex").unwrap();
24608 let standard_codex = standard.get(&codex).unwrap();
24609 let monitoring_codex = monitoring.get(&codex).unwrap();
24610 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24611 assert_eq!(
24612 monitoring_codex.capabilities.adapters.history,
24613 standard_codex.capabilities.adapters.history,
24614 );
24615 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24616 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24617 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24618 .unwrap();
24619 for required in [
24620 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24621 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24622 "@('role', 'text')",
24623 "g4a-private-provider-session-canary",
24624 "private-provider-login-identity",
24625 "$rawProof",
24626 "$env:GATE4AGENT_HOOK_URL",
24627 "g4a-private-provider-session-canary",
24628 ] {
24629 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24630 }
24631 for provider in ["grok", "kimi"] {
24632 let provider = AgentId::new(provider).unwrap();
24633 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24634 }
24635
24636 let config = NodeServerConfig::new(
24637 format!(
24638 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24639 std::process::id(),
24640 ),
24641 "fixture-token",
24642 NodeId::new("monitoring-context-fixture-node").unwrap(),
24643 [WorkspaceConfig::new(
24644 WorkspaceId::new("primary").unwrap(),
24645 std::env::current_dir().unwrap(),
24646 )
24647 .unwrap()],
24648 )
24649 .unwrap();
24650 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24651 assert!(server.shared.fixture_semantic_hook_policy);
24652 drop(server);
24653 std::fs::remove_dir_all(root).unwrap();
24654 }
24655
24656 #[cfg(feature = "fixture")]
24657 #[test]
24658 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24659 let root = temporary_workspace_root("kimi-context-only-fixture");
24660 std::fs::create_dir_all(&root).unwrap();
24661 let proof_path = root.join("context.proof");
24662 let standard = NodeServer::context_pack_fixture_catalog(
24663 proof_path.clone(),
24664 None,
24665 false,
24666 ).unwrap();
24667 let kimi = AgentId::new("kimi").unwrap();
24668 let context_only = NodeServer::context_pack_fixture_catalog(
24669 proof_path,
24670 Some(&kimi),
24671 false,
24672 )
24673 .unwrap();
24674
24675 let codex = AgentId::new("codex").unwrap();
24676 assert_eq!(
24677 standard.get(&codex).unwrap().launch.fixed_args,
24678 context_only.get(&codex).unwrap().launch.fixed_args,
24679 );
24680 let script = context_only
24681 .get(&kimi)
24682 .unwrap()
24683 .launch
24684 .fixed_args
24685 .iter()
24686 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24687 .expect("Kimi context-only validation script is missing");
24688 for required in [
24689 "$bundleArgs.Count -ne 0",
24690 "GATE4AGENT_CONTEXT_ROOT",
24691 "context-pack.json",
24692 "$entries.Count -ne 1",
24693 "g4a-context-pack-v1",
24694 "$document.PSObject.Properties['cwd']",
24695 "'claude', 'codex', 'grok', 'kimi'",
24696 "$hasUser",
24697 "$hasAssistant",
24698 "SHA256",
24699 "context-only",
24700 ] {
24701 assert!(script.contains(required), "missing validation: {required}");
24702 }
24703 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24704 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24705 }
24706 assert!(!standard
24707 .get(&kimi)
24708 .unwrap()
24709 .launch
24710 .fixed_args
24711 .iter()
24712 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24713
24714 std::fs::remove_dir_all(root).unwrap();
24715 }
24716
24717 #[cfg(all(feature = "fixture", windows))]
24718 #[tokio::test]
24719 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24720 let root = temporary_workspace_root("kimi-context-only-spawn");
24721 let workspace_root = root.join("workspace");
24722 std::fs::create_dir_all(&workspace_root).unwrap();
24723 let materialization_root = root.join("private-materializations");
24724 let proof_path = root.join("context.proof");
24725 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24726 let workspace_id = WorkspaceId::new("primary").unwrap();
24727 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24728 let endpoint = format!(
24729 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24730 std::process::id(),
24731 unix_time_ms(),
24732 );
24733 let spawn_profiles = SpawnProfileRegistry::new([
24734 crate::protocol::SpawnProfileDefaults {
24735 profile_id: profile_id.clone(),
24736 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24737 provider: agent("kimi"),
24738 mode: SessionMode::Pty,
24739 terminal_size: gate4agent_types::TerminalSize {
24740 rows: 30,
24741 columns: 120,
24742 },
24743 prompt: None,
24744 bundle_id: None,
24745 context_id: None,
24746 environment_profile_id: None,
24747 },
24748 ])
24749 .unwrap();
24750 let mut config = NodeServerConfig::new(
24751 &endpoint,
24752 "fixture-token",
24753 node_id.clone(),
24754 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24755 )
24756 .unwrap()
24757 .with_spawn_profiles(spawn_profiles)
24758 .with_session_environment_materialization(
24759 materialization_root,
24760 Arc::new(FixtureSecretResolver {
24761 deny: Arc::new(AtomicBool::new(false)),
24762 }),
24763 )
24764 .unwrap();
24765 config.fixture_raw_pty_runtime = true;
24766 let server = NodeServer::new_context_only_proof_fixture(
24767 config,
24768 agent("kimi"),
24769 proof_path.clone(),
24770 )
24771 .unwrap();
24772 let shared = Arc::clone(&server.shared);
24773 let shutdown = server.shutdown_handle();
24774
24775 let history = HistorySessionRecord {
24776 session_id: "context-only-source-history".to_owned(),
24777 title: None,
24778 cwd: None,
24779 model: None,
24780 message_count: 2,
24781 completed_turn_count: None,
24782 total_tokens: 0,
24783 messages: vec![
24784 gate4agent_types::HistoryMessageRecord {
24785 role: gate4agent_types::HistoryMessageRole::User,
24786 text: "continue the bounded parent run through native C2".to_owned(),
24787 },
24788 gate4agent_types::HistoryMessageRecord {
24789 role: gate4agent_types::HistoryMessageRole::Assistant,
24790 text: "the exact parent context is ready for the child run".to_owned(),
24791 },
24792 ],
24793 };
24794 let pack = NodeContextPack::export(
24795 ContextPackLineageReceipt {
24796 source_node_id: node_id.clone(),
24797 source_session: SessionAddress {
24798 workspace_id: workspace_id.clone(),
24799 session: SessionKey {
24800 instance_id: AgentInstanceId(71),
24801 generation: SessionGeneration(1),
24802 },
24803 },
24804 source_provider: agent("codex"),
24805 },
24806 &history,
24807 )
24808 .unwrap();
24809 let expected_context_hash = digest(&SHA256, pack.bytes())
24810 .as_ref()
24811 .iter()
24812 .map(|byte| format!("{byte:02x}"))
24813 .collect::<String>();
24814 let context = shared
24815 .context_catalog
24816 .write()
24817 .unwrap_or_else(|poisoned| poisoned.into_inner())
24818 .insert(pack)
24819 .unwrap();
24820 let spec = SpawnSpec {
24821 target: crate::protocol::SpawnTarget {
24822 node_id,
24823 workspace_id: workspace_id.clone(),
24824 worktree_id: None,
24825 },
24826 profile_id,
24827 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24828 "target-kimi-r1",
24829 ).unwrap(),
24830 overrides: crate::protocol::SpawnOverrides {
24831 context_id: crate::protocol::SpawnOverride::Set {
24832 value: context.id.clone(),
24833 },
24834 ..crate::protocol::SpawnOverrides::default()
24835 },
24836 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24837 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24838 required_capabilities: SpawnRequiredCapabilities::default(),
24839 };
24840 let resolved = match shared.resolve_spawn_spec(&spec) {
24841 Ok(resolved) => resolved,
24842 Err(error) => panic!(
24843 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24844 error.code,
24845 error.message,
24846 ),
24847 };
24848 let resolved_context = match shared.resolve_context(&resolved) {
24849 Ok(Some(resolved_context)) => resolved_context,
24850 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24851 Err(error) => panic!(
24852 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24853 error.code,
24854 error.message,
24855 ),
24856 };
24857 assert_eq!(resolved_context, context);
24858 let runtime_policy = match shared
24859 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24860 .await
24861 {
24862 Ok(runtime_policy) => runtime_policy,
24863 Err(error) => panic!(
24864 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24865 error.code,
24866 error.message,
24867 ),
24868 };
24869 assert!(runtime_policy.raw_pty_lifecycle);
24870
24871 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24872 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24873 agent("kimi"),
24874 TransportKind::Pty,
24875 vec![EnvMutation {
24876 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24877 value: Some(OsString::from("must-remain-rejected")),
24878 }],
24879 )
24880 .unwrap();
24881 assert_eq!(
24882 launch_control
24883 .install_native_launch_environment_overlay(
24884 AgentInstanceId(72),
24885 generic_overlay,
24886 )
24887 .unwrap_err(),
24888 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24889 );
24890
24891 let diagnostic_address = SessionAddress {
24892 workspace_id: workspace_id.clone(),
24893 session: SessionKey {
24894 instance_id: AgentInstanceId(73),
24895 generation: SessionGeneration(1),
24896 },
24897 };
24898 let (diagnostic_overlay, diagnostic_materialization) = match shared
24899 .prepare_session_materialization(
24900 &diagnostic_address,
24901 &agent("kimi"),
24902 SessionMode::Pty,
24903 None,
24904 None,
24905 Some(&resolved_context),
24906 None,
24907 )
24908 {
24909 Ok(Some(prepared)) => prepared,
24910 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24911 Err(error) => panic!(
24912 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24913 error.code,
24914 error.message,
24915 ),
24916 };
24917 let diagnostic_overlay = diagnostic_overlay
24918 .expect("Kimi context-only materialization omitted its environment overlay");
24919 assert!(matches!(
24920 &diagnostic_overlay,
24921 PreparedNativeLaunchOverlay::Environment(_),
24922 ));
24923 match shared.install_prepared_launch_overlay(
24924 diagnostic_address.session.instance_id,
24925 diagnostic_overlay,
24926 ) {
24927 Ok(None) => {}
24928 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24929 Err(error) => panic!(
24930 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24931 error.code,
24932 error.message,
24933 ),
24934 }
24935 assert!(launch_control.clear_native_instance_launch_overlay(
24936 diagnostic_address.session.instance_id,
24937 ));
24938 drop(diagnostic_materialization);
24939 assert!(shared
24940 .materializations
24941 .lock()
24942 .unwrap_or_else(|poisoned| poisoned.into_inner())
24943 .is_empty());
24944
24945 let node_task = tokio::spawn(server.run());
24946 let ready_client = timeout(Duration::from_secs(5), async {
24947 loop {
24948 match gate4agent_node_wire::LocalNodeClient::connect(
24949 &endpoint,
24950 &shared.node_id,
24951 ClientRole::Observer,
24952 "fixture-token",
24953 )
24954 .await
24955 {
24956 Ok(client) => break client,
24957 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24958 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24959 }
24960 }
24961 })
24962 .await
24963 .expect("Kimi context-only Node did not become ready");
24964 assert_eq!(
24965 ready_client
24966 .hello()
24967 .snapshot
24968 .provider_runtime_statuses
24969 .iter()
24970 .find(|status| status.provider().as_str() == "kimi")
24971 .expect("Kimi runtime status is missing")
24972 .mode(),
24973 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24974 );
24975 drop(ready_client);
24976
24977 let receipt = match shared.spawn_from_spec(spec).await {
24978 Ok(receipt) => receipt,
24979 Err(error) => panic!(
24980 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24981 error.code,
24982 error.message,
24983 ),
24984 };
24985 assert_eq!(receipt.provider, agent("kimi"));
24986 assert_eq!(receipt.context.as_ref(), Some(&context));
24987 assert!(receipt.context_binding_is_valid());
24988 timeout(Duration::from_secs(5), async {
24989 while !proof_path.is_file() {
24990 sleep(Duration::from_millis(10)).await;
24991 }
24992 })
24993 .await
24994 .expect("Kimi context-only fixture did not write its proof");
24995
24996 let proof = std::fs::read_to_string(&proof_path).unwrap();
24997 let lines = proof.lines().collect::<Vec<_>>();
24998 assert_eq!(lines.len(), 7);
24999 assert_eq!(lines[0], "context-only");
25000 assert!(Path::new(lines[1]).is_absolute());
25001 assert_ne!(
25002 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25003 std::fs::canonicalize(&workspace_root).unwrap(),
25004 );
25005 assert_eq!(
25006 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25007 std::fs::canonicalize(&workspace_root).unwrap(),
25008 );
25009 assert_eq!(lines[3], expected_context_hash);
25010 assert_eq!(lines[4], "g4a-context-pack-v1");
25011 assert_eq!(lines[5], "codex");
25012 assert_eq!(lines[6], "2");
25013 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25014 session.instance_id == receipt.session.session.instance_id
25015 && session.agent_id.as_str() == "kimi"
25016 }));
25017
25018 shutdown.request_shutdown().await.unwrap();
25019 timeout(Duration::from_secs(5), node_task)
25020 .await
25021 .unwrap()
25022 .unwrap()
25023 .unwrap();
25024 drop(shutdown);
25025 drop(shared);
25026 std::fs::remove_dir_all(root).unwrap();
25027 }
25028
25029 #[cfg(feature = "fixture")]
25030 #[test]
25031 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25032 let root = temporary_workspace_root("clean-exit-constructor");
25033 std::fs::create_dir_all(&root).unwrap();
25034 let config = construction_test_config("clean-exit-constructor")
25035 .with_state_path(root.join("node-state.json"))
25036 .unwrap();
25037 let server = NodeServer::new_clean_exit_fixture(
25038 config,
25039 root.clone(),
25040 root.join("started.marker"),
25041 root.join("release.signal"),
25042 )
25043 .unwrap();
25044 assert_eq!(
25045 server
25046 .shared
25047 .enabled_providers
25048 .iter()
25049 .map(AgentId::as_str)
25050 .collect::<Vec<_>>(),
25051 ["claude"],
25052 );
25053 drop(server);
25054 std::fs::remove_dir_all(root).unwrap();
25055 }
25056
25057 #[test]
25058 fn production_registry_provider_contract_manifest_is_exact() {
25059 let registry = active_registry().unwrap();
25060 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25061 assert_eq!(
25062 providers
25063 .iter()
25064 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25065 .collect::<Vec<_>>(),
25066 vec![
25067 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25068 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25069 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25070 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25071 ]
25072 );
25073 assert_eq!(
25074 adapters
25075 .iter()
25076 .map(|contract| (
25077 contract.provider.as_str(),
25078 contract.family,
25079 contract.adapter_id.as_str(),
25080 contract.revision.as_str(),
25081 ))
25082 .collect::<Vec<_>>(),
25083 vec![
25084 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25085 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25086 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25087 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25088 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25089 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25090 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25091 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25092 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25093 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25094 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25095 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25096 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25097 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25098 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25099 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25100 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25101 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25102 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25103 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25104 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25105 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25106 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25107 ]
25108 );
25109 }
25110
25111 fn construction_test_config(name: &str) -> NodeServerConfig {
25112 let workspace = WorkspaceConfig::new(
25113 WorkspaceId::new("primary").unwrap(),
25114 std::env::current_dir().unwrap(),
25115 )
25116 .unwrap();
25117 NodeServerConfig::new(
25118 format!(r"\\.\pipe\gate4agent-{name}"),
25119 "fixture-token",
25120 NodeId::new(name).unwrap(),
25121 [workspace],
25122 )
25123 .unwrap()
25124 }
25125
25126 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25127 let adapter = AdapterBinding::new(
25128 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25129 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25130 gate4agent_types::AdapterVerification::SyntheticFixture,
25131 )
25132 .unwrap();
25133 let agent_ids = ["claude", "codex", "kimi"]
25134 .into_iter()
25135 .map(|agent_id| AgentId::new(agent_id).unwrap())
25136 .collect::<Vec<_>>();
25137 let adapters = gate4agent_catalog::AdapterRegistry::new(
25138 [
25139 AdapterFamily::PtySemantic,
25140 AdapterFamily::Pipe,
25141 AdapterFamily::OneShot,
25142 AdapterFamily::Acp,
25143 AdapterFamily::Hook,
25144 AdapterFamily::ManagedHook,
25145 AdapterFamily::History,
25146 AdapterFamily::Resume,
25147 AdapterFamily::SessionOptions,
25148 AdapterFamily::CapabilityProbe,
25149 ]
25150 .into_iter()
25151 .map(|family| gate4agent_catalog::AdapterDescriptor {
25152 family,
25153 binding: adapter.clone(),
25154 agents: agent_ids.clone(),
25155 }),
25156 )
25157 .unwrap();
25158 let specs = agent_ids
25159 .iter()
25160 .map(|agent_id| {
25161 let agent_id = agent_id.as_str();
25162 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25163 spec.revision =
25164 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25165 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25166 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25167 adapter: adapter.clone(),
25168 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25169 launch_override: None,
25170 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25171 });
25172 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25173 adapter: adapter.clone(),
25174 launch_override: None,
25175 });
25176 spec.capabilities.adapters.hook = Some(adapter.clone());
25177 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25178 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25179 spec.capabilities.adapters.history = Some(adapter.clone());
25180 spec.capabilities.adapters.resume = Some(adapter.clone());
25181 spec.capabilities.adapters.session_options = Some(adapter.clone());
25182 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25183 spec
25184 })
25185 .collect::<Vec<_>>();
25186 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25187 }
25188
25189 #[test]
25197 fn node_server_construction_accepts_the_production_manifest() {
25198 let registry = active_registry().unwrap();
25199 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25200 assert_eq!(providers.len(), registry.iter().count());
25201 assert!(!providers.is_empty());
25202 let server = NodeServer::new_with_registry(
25203 construction_test_config("manifest-production-bound"),
25204 registry,
25205 )
25206 .unwrap();
25207 drop(server);
25208 }
25209
25210 #[test]
25211 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25212 let registry = maximum_size_provider_manifest_registry();
25213 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25214 assert_eq!(providers.len(), 3);
25215 assert_eq!(adapters.len(), 30);
25216 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25217 let error = match NodeServer::new_with_registry(
25218 construction_test_config("manifest-handshake-overflow"),
25219 registry,
25220 ) {
25221 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25222 Err(error) => error,
25223 };
25224 assert!(matches!(
25225 error,
25226 NodeServerError::ProviderContractManifest(message)
25227 if message.contains("authentication binding length")
25228 && message.contains("8192-byte limit")
25229 ));
25230 }
25231
25232 #[test]
25233 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25234 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25235 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25236 let registry = AgentRegistry::new([spec]).unwrap();
25237 assert!(matches!(
25238 provider_contract_manifest(®istry),
25239 Err(NodeServerError::ProviderContractManifest(message))
25240 if message.contains("exceeds the 128-byte limit")
25241 ));
25242 }
25243
25244 #[test]
25245 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25246 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25247 spec.revision = "invalid revision".to_owned();
25248 let registry = AgentRegistry::new([spec]).unwrap();
25249 assert!(matches!(
25250 provider_contract_manifest(®istry),
25251 Err(NodeServerError::ProviderContractManifest(message))
25252 if message.contains("bounded lowercase ASCII")
25253 ));
25254 }
25255
25256 #[test]
25257 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25258 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25259 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25260 let conflicting_binding = builtin_registry()
25261 .get_by_id("codex")
25262 .unwrap()
25263 .capabilities
25264 .adapters
25265 .one_shot
25266 .clone()
25267 .unwrap();
25268 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25269 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25270 pipe.adapter = transport_binding;
25271 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25272 let registry = AgentRegistry::new([spec]).unwrap();
25273 assert!(matches!(
25274 provider_contract_manifest(®istry),
25275 Err(NodeServerError::ProviderContractManifest(message))
25276 if message.contains("conflicting OneShot adapter bindings")
25277 ));
25278 }
25279
25280 #[test]
25281 fn public_node_failures_expose_only_fixed_categories() {
25282 let provider = failure(
25283 NodeFailureCode::BackendOperationFailed,
25284 r"provider bearer-secret failed at C:\private\session.jsonl",
25285 );
25286 assert_eq!(provider.message, "backend-operation-failed");
25287 assert!(!provider.message.contains("bearer-secret"));
25288 assert!(!provider.message.contains(r"C:\private"));
25289 let persistence = persistence_failure(io::Error::new(
25290 io::ErrorKind::Other,
25291 r"state write failed at C:\private\state-v1.json",
25292 ));
25293 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25294 assert!(!persistence.message.contains(r"C:\private"));
25295
25296 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25297 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25298 assert_eq!(
25299 generic.to_string(),
25300 "node durable state failed: durable-state-load-failed",
25301 );
25302 }
25303 }
25304
25305 #[test]
25306 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25307 let sequence = b"\x1b[1;5D".to_vec();
25308 assert_eq!(
25309 terminal_bytes_action(sequence.clone()).unwrap(),
25310 InputAction::TerminalBytes(sequence),
25311 );
25312 assert_eq!(
25313 terminal_bytes_action(Vec::new()).unwrap_err().code,
25314 NodeFailureCode::InvalidRequest,
25315 );
25316 assert_eq!(
25317 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25318 .unwrap_err()
25319 .code,
25320 NodeFailureCode::InvalidRequest,
25321 );
25322 }
25323
25324 #[test]
25325 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25326 let root = temporary_workspace_root("exclusive-state-owner");
25327 std::fs::create_dir_all(&root).unwrap();
25328 let workspace = WorkspaceConfig::new(
25329 WorkspaceId::new("primary").unwrap(),
25330 &root,
25331 )
25332 .unwrap();
25333 let state_path = root.join("state-v1.json");
25334 let endpoint = format!(
25335 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25336 std::process::id(),
25337 std::time::SystemTime::now()
25338 .duration_since(std::time::UNIX_EPOCH)
25339 .unwrap()
25340 .as_nanos(),
25341 );
25342 let config = NodeServerConfig::new(
25343 endpoint,
25344 "fixture-token",
25345 NodeId::new("node-1").unwrap(),
25346 [workspace],
25347 )
25348 .unwrap()
25349 .with_state_path(&state_path)
25350 .unwrap();
25351
25352 let first = NodeServer::new(config.clone()).unwrap();
25353 let error = match NodeServer::new(config.clone()) {
25354 Ok(_) => panic!("a second node server acquired the same durable state path"),
25355 Err(error) => error,
25356 };
25357 assert!(matches!(
25358 &error,
25359 NodeServerError::DurableState(error)
25360 if error.kind() == io::ErrorKind::WouldBlock
25361 ));
25362 assert_eq!(
25363 error.to_string(),
25364 "node durable state failed: durable-state-lock-failed",
25365 );
25366 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25367 drop(first);
25368 let second = NodeServer::new(config).unwrap();
25369 drop(second);
25370 std::fs::remove_dir_all(root).unwrap();
25371 }
25372
25373 #[test]
25374 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25375 let catalog = active_registry().unwrap();
25376 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25377 let workspace_id = WorkspaceId::new("primary").unwrap();
25378 let workspace = WorkspaceConfig::new(
25379 workspace_id.clone(),
25380 std::env::current_dir().unwrap(),
25381 )
25382 .unwrap();
25383 let shared = NodeShared::new(
25384 handle,
25385 "fixture-token".to_owned(),
25386 NodeId::new("node-1").unwrap(),
25387 vec![workspace.clone()],
25388 vec![agent("claude"), agent("codex"), agent("kimi")],
25389 );
25390 let instance_id = AgentInstanceId(17);
25391 let generation = SessionGeneration(3);
25392 assert!(shared.address_for(instance_id, generation).is_none());
25393 shared.bind_session(instance_id, workspace_id.clone(), generation);
25394 let address = SessionAddress {
25395 workspace_id,
25396 session: SessionKey { instance_id, generation },
25397 };
25398 assert_eq!(
25399 shared.workspace_root(&address.workspace_id).unwrap(),
25400 workspace.canonical_root,
25401 );
25402 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25403 shared.remove_binding(&address);
25404 assert!(shared.address_for(instance_id, generation).is_none());
25405 }
25406
25407 #[test]
25408 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25409 let address = SessionAddress {
25410 workspace_id: WorkspaceId::new("primary").unwrap(),
25411 session: SessionKey {
25412 instance_id: AgentInstanceId(7),
25413 generation: SessionGeneration(2),
25414 },
25415 };
25416 let record_id = SessionRecordId::new("record-a").unwrap();
25417 let size = gate4agent_types::TerminalSize {
25418 rows: 24,
25419 columns: 80,
25420 };
25421 let address_requests = [
25422 NodeRequest::Resume {
25423 session: address.clone(),
25424 terminal_size: size,
25425 initial_prompt: None,
25426 },
25427 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25428 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25429 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25430 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25431 NodeRequest::TerminalControl {
25432 session: address.clone(),
25433 control: TerminalControl::Enter,
25434 },
25435 NodeRequest::Resize { session: address.clone(), size },
25436 NodeRequest::Interrupt { session: address.clone() },
25437 NodeRequest::Stop { session: address.clone(), force: false },
25438 NodeRequest::Remove { session: address },
25439 ];
25440 for request in &address_requests {
25441 assert!(request_requires_open_provider_ids_with(
25442 request,
25443 |_| Some(agent("third-party-agent")),
25444 |_| Some(agent("claude")),
25445 ));
25446 assert!(!request_requires_open_provider_ids_with(
25447 request,
25448 |_| Some(agent("claude")),
25449 |_| Some(agent("claude")),
25450 ));
25451 assert!(request_requires_open_provider_ids_with(
25452 request,
25453 |_| None,
25454 |_| Some(agent("claude")),
25455 ));
25456 }
25457
25458 let record_requests = [
25459 NodeRequest::RenameSessionRecord {
25460 record_id: record_id.clone(),
25461 display_name: "renamed".to_owned(),
25462 },
25463 NodeRequest::ResumeSessionRecord {
25464 record_id: record_id.clone(),
25465 terminal_size: size,
25466 initial_prompt: None,
25467 },
25468 NodeRequest::ForgetSessionRecord { record_id },
25469 ];
25470 for request in &record_requests {
25471 assert!(request_requires_open_provider_ids_with(
25472 request,
25473 |_| Some(agent("claude")),
25474 |_| Some(agent("grok")),
25475 ));
25476 assert!(!request_requires_open_provider_ids_with(
25477 request,
25478 |_| Some(agent("claude")),
25479 |_| Some(agent("kimi")),
25480 ));
25481 assert!(request_requires_open_provider_ids_with(
25482 request,
25483 |_| Some(agent("claude")),
25484 |_| None,
25485 ));
25486 }
25487
25488 assert!(request_requires_open_provider_ids_with(
25489 &NodeRequest::Spawn {
25490 workspace_id: WorkspaceId::new("primary").unwrap(),
25491 provider: agent("third-party-agent"),
25492 mode: SessionMode::Pty,
25493 terminal_size: size,
25494 initial_prompt: None,
25495 },
25496 |_| None,
25497 |_| None,
25498 ));
25499 assert!(!request_requires_open_provider_ids_with(
25500 &NodeRequest::Snapshot,
25501 |_| None,
25502 |_| None,
25503 ));
25504 assert!(request_requires_open_provider_ids_with(
25505 &NodeRequest::ForgetContextPack {
25506 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25507 },
25508 |_| None,
25509 |_| None,
25510 ));
25511 }
25512
25513 #[test]
25514 fn legacy_projection_filters_open_record_replies_and_event_history() {
25515 let catalog = active_registry().unwrap();
25516 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25517 let workspace = WorkspaceConfig::new(
25518 WorkspaceId::new("primary").unwrap(),
25519 std::env::current_dir().unwrap(),
25520 )
25521 .unwrap();
25522 let shared = NodeShared::new(
25523 handle,
25524 "fixture-token".to_owned(),
25525 NodeId::new("node-1").unwrap(),
25526 vec![workspace],
25527 vec![agent("claude"), agent("grok")],
25528 );
25529 let legacy = record("claude", "legacy-record");
25530 let open = record("grok", "open-record");
25531 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25532 record: legacy.clone(),
25533 });
25534 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25535 record: open.clone(),
25536 });
25537 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25538 record_id: legacy.record_id.clone(),
25539 });
25540 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25541 record_id: open.record_id.clone(),
25542 });
25543 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25544 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25545 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25546 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25547
25548 let mut record_reply = ResponseEnvelope {
25549 request_id: 1,
25550 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25551 };
25552 project_response_legacy_provider_ids(&shared, &mut record_reply);
25553 assert!(matches!(
25554 record_reply.result,
25555 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25556 ));
25557
25558 let mut resync = ResponseEnvelope {
25559 request_id: 2,
25560 result: Ok(NodeResponse::Resync {
25561 event_sequence: open_removed.sequence,
25562 oldest_available_sequence: 1,
25563 snapshot: shared.snapshot(),
25564 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25565 }),
25566 };
25567 project_response_legacy_provider_ids(&shared, &mut resync);
25568 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25569 panic!("legacy projection changed the resync response kind");
25570 };
25571 assert!(snapshot.session_records.iter().all(|item| {
25572 provider_id_is_legacy(&item.provider)
25573 }));
25574 assert_eq!(events.len(), 2);
25575 assert!(events.iter().all(|event| matches!(
25576 &event.event,
25577 NodeEvent::SessionRecordUpserted { record }
25578 if provider_id_is_legacy(&record.provider)
25579 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25580 }
25581
25582 #[test]
25583 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25584 let catalog = active_registry().unwrap();
25585 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25586 let workspace_id = WorkspaceId::new("primary").unwrap();
25587 let workspace = WorkspaceConfig::new(
25588 workspace_id.clone(),
25589 std::env::current_dir().unwrap(),
25590 )
25591 .unwrap();
25592 let shared = NodeShared::new(
25593 handle,
25594 "fixture-token".to_owned(),
25595 NodeId::new("node-1").unwrap(),
25596 vec![workspace],
25597 vec![agent("claude")],
25598 );
25599 for index in 0..CONTROL_SESSIONS_MAX {
25600 shared.bind_session(
25601 AgentInstanceId((index + 1) as u64),
25602 workspace_id.clone(),
25603 SessionGeneration(1),
25604 );
25605 }
25606 let error = shared.ensure_binding_capacity().unwrap_err();
25607 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25608 }
25609
25610 #[tokio::test]
25611 async fn provider_spawn_admission_rejects_before_session_mutation() {
25612 let catalog = active_registry().unwrap();
25613 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25614 let workspace = WorkspaceConfig::new(
25615 WorkspaceId::new("primary").unwrap(),
25616 std::env::current_dir().unwrap(),
25617 )
25618 .unwrap();
25619 let mut shared = NodeShared::new(
25620 handle,
25621 "fixture-token".to_owned(),
25622 NodeId::new("node-provider-admission").unwrap(),
25623 vec![workspace],
25624 vec![agent("claude")],
25625 );
25626 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25627 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25628 ])
25629 .unwrap();
25630 let terminal_size = gate4agent_types::TerminalSize {
25631 rows: 24,
25632 columns: 80,
25633 };
25634
25635 let unavailable = shared
25636 .spawn_session(
25637 WorkspaceId::new("primary").unwrap(),
25638 agent("claude"),
25639 SessionMode::Pty,
25640 terminal_size,
25641 None,
25642 )
25643 .await
25644 .unwrap_err();
25645 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25646 assert_eq!(unavailable.message, "backend-operation-failed");
25647
25648 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25649 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25650 agent("claude"),
25651 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25652 ),
25653 ])
25654 .unwrap();
25655 let semantic = shared
25656 .spawn_session(
25657 WorkspaceId::new("primary").unwrap(),
25658 agent("claude"),
25659 SessionMode::Inline,
25660 terminal_size,
25661 None,
25662 )
25663 .await
25664 .unwrap_err();
25665 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25666 assert_eq!(semantic.message, "unsupported-capability");
25667
25668 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25669 assert!(shared
25670 .session_bindings
25671 .lock()
25672 .unwrap_or_else(|poisoned| poisoned.into_inner())
25673 .is_empty());
25674 assert!(shared
25675 .session_records
25676 .lock()
25677 .unwrap_or_else(|poisoned| poisoned.into_inner())
25678 .records
25679 .is_empty());
25680 assert!(shared.handle.snapshot().sessions.is_empty());
25681 }
25682
25683 #[tokio::test]
25684 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25685 let catalog = active_registry().unwrap();
25686 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25687 let workspace_id = WorkspaceId::new("primary").unwrap();
25688 let workspace = WorkspaceConfig::new(
25689 workspace_id.clone(),
25690 std::env::current_dir().unwrap(),
25691 )
25692 .unwrap();
25693 let shared = NodeShared::new(
25694 handle,
25695 "fixture-token".to_owned(),
25696 NodeId::new("node-raw-spawn-record").unwrap(),
25697 vec![workspace],
25698 vec![agent("claude")],
25699 );
25700 let address = SessionAddress {
25701 workspace_id,
25702 session: SessionKey {
25703 instance_id: AgentInstanceId(71),
25704 generation: SessionGeneration::default(),
25705 },
25706 };
25707
25708 let record_id = shared
25709 .bind_spawn_session(
25710 &address,
25711 agent("claude"),
25712 SessionMode::Pty,
25713 ProviderRuntimePolicy::raw_pty(),
25714 SpawnRecordPolicy::Always,
25715 None,
25716 )
25717 .unwrap()
25718 .unwrap();
25719 shared
25720 .handle
25721 .dispatch(shared.prepare_command(ControlCommand::Register {
25722 instance_id: address.session.instance_id,
25723 agent_id: agent("claude"),
25724 transport: TransportKind::Pty,
25725 }))
25726 .unwrap();
25727 runtime.tick().await;
25728
25729 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25730 session.instance_id == address.session.instance_id
25731 && session.generation == address.session.generation
25732 }));
25733 let record = shared.record(&record_id).unwrap();
25734 assert_eq!(record.display_name, "claude #1");
25735 assert_eq!(record.state, ManagedSessionState::Live);
25736 assert_eq!(record.active_session.as_ref(), Some(&address));
25737 assert!(record.provider_session.is_none());
25738 let binding_record_id = shared
25739 .session_bindings
25740 .lock()
25741 .unwrap_or_else(|poisoned| poisoned.into_inner())
25742 .get(&address.session.instance_id)
25743 .and_then(|binding| binding.record_id.clone());
25744 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25745
25746 let renamed = shared
25747 .rename_session_record(&record_id, "raw review".to_owned())
25748 .unwrap();
25749 assert_eq!(renamed.record_id, record_id);
25750 assert_eq!(renamed.display_name, "raw review");
25751 shared.reconcile_managed_record(
25752 &record_id,
25753 &address,
25754 &ControlEvent {
25755 sequence: 1,
25756 command_id: None,
25757 instance_id: address.session.instance_id,
25758 generation: address.session.generation,
25759 event: ControlEventKind::Running { process_id: None },
25760 },
25761 false,
25762 false,
25763 );
25764 let running = shared.record(&record_id).unwrap();
25765 assert_eq!(running.state, ManagedSessionState::Live);
25766 assert_eq!(running.active_session.as_ref(), Some(&address));
25767 assert_eq!(running.display_name, "raw review");
25768
25769 shared.reconcile_managed_record(
25770 &record_id,
25771 &address,
25772 &ControlEvent {
25773 sequence: 2,
25774 command_id: None,
25775 instance_id: address.session.instance_id,
25776 generation: address.session.generation,
25777 event: ControlEventKind::Exited {
25778 exit_code: Some(0),
25779 forced: false,
25780 },
25781 },
25782 false,
25783 false,
25784 );
25785 let detached = shared.record(&record_id).unwrap();
25786 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25787 assert!(detached.active_session.is_none());
25788 assert_eq!(detached.display_name, "raw review");
25789 let renamed_detached = shared
25790 .rename_session_record(&record_id, "raw review detached".to_owned())
25791 .unwrap();
25792 assert_eq!(renamed_detached.record_id, record_id);
25793 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25794 assert!(renamed_detached.active_session.is_none());
25795 assert_eq!(renamed_detached.display_name, "raw review detached");
25796 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25797 }
25798
25799 #[test]
25800 fn legacy_raw_spawn_binding_remains_untracked() {
25801 let catalog = active_registry().unwrap();
25802 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25803 let workspace_id = WorkspaceId::new("primary").unwrap();
25804 let workspace = WorkspaceConfig::new(
25805 workspace_id.clone(),
25806 std::env::current_dir().unwrap(),
25807 )
25808 .unwrap();
25809 let shared = NodeShared::new(
25810 handle,
25811 "fixture-token".to_owned(),
25812 NodeId::new("node-legacy-raw-spawn").unwrap(),
25813 vec![workspace],
25814 vec![agent("claude")],
25815 );
25816 let address = SessionAddress {
25817 workspace_id,
25818 session: SessionKey {
25819 instance_id: AgentInstanceId(72),
25820 generation: SessionGeneration::default(),
25821 },
25822 };
25823
25824 let record_id = shared
25825 .bind_spawn_session(
25826 &address,
25827 agent("claude"),
25828 SessionMode::Pty,
25829 ProviderRuntimePolicy::raw_pty(),
25830 SpawnRecordPolicy::ProviderIdentityOnly,
25831 None,
25832 )
25833 .unwrap();
25834
25835 assert!(record_id.is_none());
25836 let binding_record_id = shared
25837 .session_bindings
25838 .lock()
25839 .unwrap_or_else(|poisoned| poisoned.into_inner())
25840 .get(&address.session.instance_id)
25841 .and_then(|binding| binding.record_id.clone());
25842 assert!(binding_record_id.is_none());
25843 assert!(shared.snapshot().session_records.is_empty());
25844 }
25845
25846 #[tokio::test]
25847 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25848 let catalog = active_registry().unwrap();
25849 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25850 let workspace_id = WorkspaceId::new("primary").unwrap();
25851 let workspace = WorkspaceConfig::new(
25852 workspace_id.clone(),
25853 std::env::current_dir().unwrap(),
25854 )
25855 .unwrap();
25856 let mut shared = NodeShared::new(
25857 handle,
25858 "fixture-token".to_owned(),
25859 NodeId::new("node-raw-semantic-gate").unwrap(),
25860 vec![workspace],
25861 vec![agent("claude")],
25862 );
25863 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25864 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25865 ])
25866 .unwrap();
25867 shared
25868 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25869 .unwrap();
25870 let address = SessionAddress {
25871 workspace_id,
25872 session: SessionKey {
25873 instance_id: AgentInstanceId(72),
25874 generation: SessionGeneration::default(),
25875 },
25876 };
25877 shared.bind_session_with_policy(
25878 &address,
25879 ProviderRuntimePolicy::raw_pty(),
25880 None,
25881 );
25882 shared
25883 .handle
25884 .dispatch(shared.prepare_command(ControlCommand::Register {
25885 instance_id: address.session.instance_id,
25886 agent_id: agent("claude"),
25887 transport: TransportKind::Pty,
25888 }))
25889 .unwrap();
25890 runtime.tick().await;
25891 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25892 let revision_before = shared.handle.snapshot().revision;
25893 let history_sequence_before = shared.current_sequence();
25894 let terminal_size = gate4agent_types::TerminalSize {
25895 rows: 24,
25896 columns: 80,
25897 };
25898
25899 for request in [
25900 NodeRequest::Prompt {
25901 session: address.clone(),
25902 text: "prompt".to_owned(),
25903 },
25904 NodeRequest::Paste {
25905 session: address.clone(),
25906 text: "paste".to_owned(),
25907 },
25908 NodeRequest::Resume {
25914 session: address.clone(),
25915 terminal_size,
25916 initial_prompt: Some("resume".to_owned()),
25917 },
25918 ] {
25919 let error = process_request_inner(
25920 &shared,
25921 77,
25922 ClientRole::Operator,
25923 request,
25924 )
25925 .await
25926 .unwrap_err();
25927 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25928 }
25929
25930 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25931 assert_eq!(shared.handle.snapshot().revision, revision_before);
25932 assert_eq!(shared.current_sequence(), history_sequence_before);
25933 let bindings = shared
25934 .session_bindings
25935 .lock()
25936 .unwrap_or_else(|poisoned| poisoned.into_inner());
25937 let binding = bindings.get(&address.session.instance_id).unwrap();
25938 assert_eq!(binding.generation, address.session.generation);
25939 assert!(binding.pending_resume.is_none());
25940 assert!(binding.record_id.is_none());
25941 }
25942
25943 #[tokio::test]
25944 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25945 let catalog = active_registry().unwrap();
25946 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25947 let primary = WorkspaceConfig::new(
25948 WorkspaceId::new("primary").unwrap(),
25949 std::env::current_dir().unwrap(),
25950 )
25951 .unwrap();
25952 let shared = NodeShared::new(
25953 handle,
25954 "fixture-token".to_owned(),
25955 NodeId::new("node-1").unwrap(),
25956 vec![primary],
25957 vec![agent("claude")],
25958 );
25959 shared
25960 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25961 .unwrap();
25962 let before = shared.snapshot();
25963 let error = process_request_inner(
25964 &shared,
25965 77,
25966 ClientRole::Operator,
25967 NodeRequest::RegisterWorkspace {
25968 workspace_id: WorkspaceId::new("foreign").unwrap(),
25969 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25970 },
25971 )
25972 .await
25973 .unwrap_err();
25974 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25975 assert_eq!(error.message, "invalid-request");
25976 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25977 }
25978
25979 #[tokio::test]
25980 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25981 let catalog = active_registry().unwrap();
25982 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25983 let primary_id = WorkspaceId::new("primary").unwrap();
25984 let primary = WorkspaceConfig::new(
25985 primary_id.clone(),
25986 std::env::current_dir().unwrap(),
25987 )
25988 .unwrap();
25989 let shared = NodeShared::new(
25990 handle,
25991 "fixture-token".to_owned(),
25992 NodeId::new("node-1").unwrap(),
25993 vec![primary.clone()],
25994 vec![agent("claude")],
25995 );
25996 let secondary_id = WorkspaceId::new("secondary").unwrap();
25997 let secondary = shared
25998 .register_workspace(
25999 secondary_id.clone(),
26000 std::env::temp_dir().to_string_lossy().into_owned(),
26001 )
26002 .await
26003 .unwrap();
26004 assert_eq!(secondary.workspace_id, secondary_id);
26005 assert!(secondary.sessions.is_empty());
26006 assert_eq!(shared.snapshot().workspaces.len(), 2);
26007
26008 let duplicate_id = shared
26009 .register_workspace(
26010 secondary_id.clone(),
26011 primary.canonical_root().to_owned(),
26012 )
26013 .await
26014 .unwrap_err();
26015 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26016 let duplicate_root = shared
26017 .register_workspace(
26018 WorkspaceId::new("duplicate-root").unwrap(),
26019 windows_path_text(&secondary.canonical_root).to_owned(),
26020 )
26021 .await
26022 .unwrap_err();
26023 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26024
26025 let address = SessionAddress {
26026 workspace_id: secondary_id.clone(),
26027 session: SessionKey {
26028 instance_id: AgentInstanceId(41),
26029 generation: SessionGeneration(1),
26030 },
26031 };
26032 shared.bind_session(
26033 address.session.instance_id,
26034 secondary_id.clone(),
26035 address.session.generation,
26036 );
26037 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26038 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26039 shared.remove_binding(&address);
26040 shared.unregister_workspace(&secondary_id).unwrap();
26041 assert_eq!(shared.snapshot().workspaces.len(), 1);
26042
26043 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26044 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26045 let history = shared
26046 .history
26047 .lock()
26048 .unwrap_or_else(|poisoned| poisoned.into_inner());
26049 assert!(matches!(
26050 &history.events[0],
26051 NodeEventEnvelope {
26052 sequence: 1,
26053 event: NodeEvent::WorkspaceAdded { workspace },
26054 } if workspace.workspace_id == secondary_id
26055 ));
26056 assert!(matches!(
26057 &history.events[1],
26058 NodeEventEnvelope {
26059 sequence: 2,
26060 event: NodeEvent::WorkspaceRemoved { workspace_id },
26061 } if workspace_id == &secondary_id
26062 ));
26063 }
26064
26065 #[tokio::test]
26066 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26067 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26068 std::fs::create_dir_all(&secondary_root).unwrap();
26069 let catalog = active_registry().unwrap();
26070 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26071 let primary_id = WorkspaceId::new("primary").unwrap();
26072 let secondary_id = WorkspaceId::new("secondary").unwrap();
26073 let shared = NodeShared::new(
26074 handle,
26075 "fixture-token".to_owned(),
26076 NodeId::new("node-session-reference-index").unwrap(),
26077 vec![
26078 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26079 .unwrap(),
26080 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26081 ],
26082 vec![agent("claude"), agent("codex")],
26083 );
26084 let identity = ProviderSessionIdentity {
26085 key: gate4agent_types::ProviderSessionKey::SessionId,
26086 id: "provider-session-42".to_owned(),
26087 transcript_path: None,
26088 };
26089 let index_request = |workspace_id: WorkspaceId,
26090 provider: AgentId,
26091 identity: ProviderSessionIdentity,
26092 display_name: &str| {
26093 NodeRequest::IndexProviderSession {
26094 workspace_id,
26095 provider,
26096 identity,
26097 display_name: display_name.to_owned(),
26098 }
26099 };
26100 let request = index_request(
26101 primary_id.clone(),
26102 agent("claude"),
26103 identity.clone(),
26104 "release shepherd",
26105 );
26106 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26107 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26108 }));
26109 let observer = process_request_inner(
26110 &shared,
26111 77,
26112 ClientRole::Observer,
26113 request.clone(),
26114 )
26115 .await
26116 .unwrap_err();
26117 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26118 let uncontrolled = process_request_inner(
26119 &shared,
26120 77,
26121 ClientRole::Operator,
26122 request.clone(),
26123 )
26124 .await
26125 .unwrap_err();
26126 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26127 shared
26128 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26129 .unwrap();
26130
26131 let indexed = process_request_inner(
26132 &shared,
26133 77,
26134 ClientRole::Operator,
26135 request.clone(),
26136 )
26137 .await
26138 .unwrap();
26139 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26140 panic!("provider session reference index returned another response");
26141 };
26142 assert_eq!(record.state, ManagedSessionState::Dormant);
26143 assert_eq!(record.mode, SessionMode::Pty);
26144 assert_eq!(record.workspace_id, primary_id);
26145 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26146 assert!(record.active_session.is_none());
26147 assert!(record.environment_profile.is_none());
26148 assert!(record.bundle.is_none());
26149 assert!(record.context.is_none());
26150
26151 let repeated = process_request_inner(
26152 &shared,
26153 77,
26154 ClientRole::Operator,
26155 index_request(
26156 primary_id.clone(),
26157 agent("claude"),
26158 identity.clone(),
26159 "ignored replacement",
26160 ),
26161 )
26162 .await
26163 .unwrap();
26164 assert!(matches!(
26165 repeated,
26166 NodeResponse::ProviderSessionIndexed { record: ref existing }
26167 if existing.record_id == record.record_id
26168 && existing.display_name == "release shepherd"
26169 ));
26170 let conflicting_workspace = process_request_inner(
26171 &shared,
26172 77,
26173 ClientRole::Operator,
26174 index_request(
26175 secondary_id,
26176 agent("claude"),
26177 identity.clone(),
26178 "release shepherd",
26179 ),
26180 )
26181 .await
26182 .unwrap_err();
26183 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26184 let conflicting_provider = process_request_inner(
26185 &shared,
26186 77,
26187 ClientRole::Operator,
26188 index_request(
26189 primary_id.clone(),
26190 agent("codex"),
26191 identity.clone(),
26192 "release shepherd",
26193 ),
26194 )
26195 .await
26196 .unwrap_err();
26197 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26198 let transcript_rejected = process_request_inner(
26199 &shared,
26200 77,
26201 ClientRole::Operator,
26202 index_request(
26203 primary_id,
26204 agent("claude"),
26205 ProviderSessionIdentity {
26206 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26207 ..identity
26208 },
26209 "release shepherd",
26210 ),
26211 )
26212 .await
26213 .unwrap_err();
26214 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26215 let upserts = shared
26216 .history
26217 .lock()
26218 .unwrap_or_else(|poisoned| poisoned.into_inner())
26219 .events
26220 .iter()
26221 .filter(|event| matches!(
26222 &event.event,
26223 NodeEvent::SessionRecordUpserted { record: published }
26224 if published.record_id == record.record_id
26225 ))
26226 .count();
26227 assert_eq!(upserts, 1);
26228 std::fs::remove_dir_all(secondary_root).unwrap();
26229 }
26230
26231 #[tokio::test]
26232 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26233 let fixture_root = temporary_workspace_root("standalone-independent");
26234 let target = fixture_root.join("independent");
26235 std::fs::create_dir_all(&fixture_root).unwrap();
26236 let catalog = active_registry().unwrap();
26237 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26238 let selected = WorkspaceConfig::new(
26239 WorkspaceId::new("primary").unwrap(),
26240 std::env::current_dir().unwrap(),
26241 )
26242 .unwrap();
26243 let selected_root = selected.canonical_root().to_owned();
26244 let shared = NodeShared::new(
26245 handle,
26246 "fixture-token".to_owned(),
26247 NodeId::new("node-standalone").unwrap(),
26248 vec![selected],
26249 vec![agent("claude")],
26250 );
26251 let workspace_id = WorkspaceId::new("independent").unwrap();
26252 let request = NodeRequest::CreateStandaloneWorkspace {
26253 workspace_id: workspace_id.clone(),
26254 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26255 initial_branch: Some("main".to_owned()),
26256 };
26257 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26258 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26259 }));
26260 assert!(!request_uses_unnegotiated_capability(
26261 &request,
26262 &baseline_capabilities().unwrap(),
26263 ));
26264 assert!(request_uses_unnegotiated_capability(&request, &[]));
26265
26266 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26267 .await
26268 .unwrap_err();
26269 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26270 assert!(!target.exists());
26271 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26272 .await
26273 .unwrap_err();
26274 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26275 assert!(!target.exists());
26276 shared
26277 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26278 .unwrap();
26279
26280 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26281 .await
26282 .unwrap();
26283 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26284 panic!("standalone workspace creation returned another response");
26285 };
26286 assert_eq!(workspace.workspace_id, workspace_id);
26287 assert!(target.join(".git").is_dir());
26288 let branch = run_git_bounded(
26289 target.to_str().unwrap(),
26290 &["branch", "--show-current"],
26291 GIT_OUTPUT_MAX_BYTES,
26292 )
26293 .await
26294 .unwrap();
26295 assert!(branch.success);
26296 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26297 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26298 assert_eq!(worktrees.len(), 1);
26299 let listed_root = WorkspaceConfig::new(
26300 WorkspaceId::new("listed-independent").unwrap(),
26301 &worktrees[0].path,
26302 )
26303 .unwrap();
26304 assert!(platform::roots_equal(
26305 listed_root.canonical_root(),
26306 windows_path_text(&workspace.canonical_root),
26307 ));
26308 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26309 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26310 snapshot.workspace_id == workspace_id
26311 && snapshot.canonical_root == workspace.canonical_root
26312 }));
26313 assert!(shared
26314 .history
26315 .lock()
26316 .unwrap_or_else(|poisoned| poisoned.into_inner())
26317 .events
26318 .iter()
26319 .any(|event| matches!(
26320 &event.event,
26321 NodeEvent::WorkspaceAdded { workspace }
26322 if workspace.workspace_id == workspace_id
26323 )));
26324 std::fs::remove_dir_all(fixture_root).unwrap();
26325 }
26326
26327 #[tokio::test]
26328 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26329 let fixture_root = temporary_workspace_root("standalone-compensation");
26330 let created_target = fixture_root.join("created-target");
26331 let existing_target = fixture_root.join("existing-target");
26332 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26333 let invalid_state_path = invalid_state_parent.join("state.json");
26334 std::fs::create_dir_all(&existing_target).unwrap();
26335 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26336 let catalog = active_registry().unwrap();
26337 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26338 let primary = WorkspaceConfig::new(
26339 WorkspaceId::new("primary").unwrap(),
26340 std::env::current_dir().unwrap(),
26341 )
26342 .unwrap();
26343 let mut shared = NodeShared::new(
26344 handle,
26345 "fixture-token".to_owned(),
26346 NodeId::new("node-standalone-compensation").unwrap(),
26347 vec![primary],
26348 vec![agent("claude")],
26349 );
26350 shared.state_path = Some(invalid_state_path);
26351
26352 let created_recovery = shared
26353 .create_standalone_workspace(
26354 WorkspaceId::new("compensated").unwrap(),
26355 created_target.to_string_lossy().into_owned(),
26356 Some("main".to_owned()),
26357 )
26358 .await
26359 .unwrap_err();
26360 assert_eq!(
26361 created_recovery.code,
26362 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26363 );
26364 assert!(created_target.join(".git").is_dir());
26365 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26366 workspace.workspace_id.as_str() != "compensated"
26367 }));
26368
26369 let existing_recovery = shared
26370 .create_standalone_workspace(
26371 WorkspaceId::new("restored").unwrap(),
26372 existing_target.to_string_lossy().into_owned(),
26373 None,
26374 )
26375 .await
26376 .unwrap_err();
26377 assert_eq!(
26378 existing_recovery.code,
26379 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26380 );
26381 assert!(existing_target.is_dir());
26382 assert!(existing_target.join(".git").is_dir());
26383 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26384 workspace.workspace_id.as_str() != "restored"
26385 }));
26386
26387 let registered_empty = fixture_root.join("registered-empty");
26388 std::fs::create_dir(®istered_empty).unwrap();
26389 let catalog = active_registry().unwrap();
26390 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26391 let registered = WorkspaceConfig::new(
26392 WorkspaceId::new("registered-empty").unwrap(),
26393 ®istered_empty,
26394 )
26395 .unwrap();
26396 let duplicate_shared = NodeShared::new(
26397 handle,
26398 "fixture-token".to_owned(),
26399 NodeId::new("node-standalone-duplicate-root").unwrap(),
26400 vec![registered],
26401 vec![agent("claude")],
26402 );
26403 let duplicate = duplicate_shared
26404 .create_standalone_workspace(
26405 WorkspaceId::new("duplicate-root").unwrap(),
26406 registered_empty.to_string_lossy().into_owned(),
26407 None,
26408 )
26409 .await
26410 .unwrap_err();
26411 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26412 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26413 std::fs::remove_dir_all(fixture_root).unwrap();
26414 }
26415
26416 #[test]
26417 fn managed_record_id_collision_preserves_the_existing_record() {
26418 let catalog = active_registry().unwrap();
26419 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26420 let workspace = WorkspaceConfig::new(
26421 WorkspaceId::new("primary").unwrap(),
26422 std::env::current_dir().unwrap(),
26423 )
26424 .unwrap();
26425 let shared = NodeShared::new(
26426 handle,
26427 "fixture-token".to_owned(),
26428 NodeId::new("node-1").unwrap(),
26429 vec![workspace.clone()],
26430 vec![agent("claude")],
26431 );
26432 let record_id = SessionRecordId::new("sr-collision").unwrap();
26433 let original = ManagedSessionRecord {
26434 record_id: record_id.clone(),
26435 display_name: "original".to_owned(),
26436 provider: agent("claude"),
26437 mode: SessionMode::Pty,
26438 state: ManagedSessionState::Dormant,
26439 workspace_id: workspace.workspace_id,
26440 canonical_root: opaque_windows_path(workspace.canonical_root),
26441 provider_session: None,
26442 active_session: None,
26443 environment_profile: None,
26444 bundle: None,
26445 context_id: None,
26446 context: None,
26447 exported_context: None,
26448 task_binding: None,
26449 created_at_unix_ms: 1,
26450 updated_at_unix_ms: 1,
26451 last_error: None,
26452 };
26453 shared.insert_record(original.clone()).unwrap();
26454 let mut collision = original.clone();
26455 collision.display_name = "replacement".to_owned();
26456 let error = shared.insert_record(collision).unwrap_err();
26457 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26458 assert_eq!(shared.record(&record_id).unwrap(), original);
26459 }
26460
26461 #[test]
26462 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26463 let catalog = active_registry().unwrap();
26464 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26465 let workspace = WorkspaceConfig::new(
26466 WorkspaceId::new("primary").unwrap(),
26467 std::env::current_dir().unwrap(),
26468 )
26469 .unwrap();
26470 let shared = NodeShared::new(
26471 handle,
26472 "fixture-token".to_owned(),
26473 NodeId::new("node-1").unwrap(),
26474 vec![workspace.clone()],
26475 vec![agent("claude")],
26476 );
26477 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26478 shared
26479 .insert_record(ManagedSessionRecord {
26480 record_id: record_id.clone(),
26481 display_name: "runtime error".to_owned(),
26482 provider: agent("claude"),
26483 mode: SessionMode::Pty,
26484 state: ManagedSessionState::IdentityPending,
26485 workspace_id: workspace.workspace_id,
26486 canonical_root: opaque_windows_path(workspace.canonical_root),
26487 provider_session: None,
26488 active_session: None,
26489 environment_profile: None,
26490 bundle: None,
26491 context_id: None,
26492 context: None,
26493 exported_context: None,
26494 task_binding: None,
26495 created_at_unix_ms: 1,
26496 updated_at_unix_ms: 1,
26497 last_error: None,
26498 })
26499 .unwrap();
26500
26501 shared
26502 .mark_record_error(
26503 &record_id,
26504 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26505 )
26506 .unwrap();
26507 let record = shared.record(&record_id).unwrap();
26508 assert_eq!(record.state, ManagedSessionState::Unavailable);
26509 assert_eq!(
26510 record.last_error.as_deref(),
26511 Some("provider-runtime-failed"),
26512 );
26513 let snapshot = shared.snapshot();
26514 assert_eq!(snapshot.session_records, vec![record]);
26515 }
26516
26517 #[test]
26526 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26527 let catalog = active_registry().unwrap();
26528 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26529 let workspace = WorkspaceConfig::new(
26530 WorkspaceId::new("primary").unwrap(),
26531 std::env::current_dir().unwrap(),
26532 )
26533 .unwrap();
26534 let shared = NodeShared::new(
26535 handle,
26536 "fixture-token".to_owned(),
26537 NodeId::new("node-1").unwrap(),
26538 vec![workspace],
26539 vec![agent("claude")],
26540 );
26541
26542 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26543
26544 shared.record_rejected_command(
26545 CommandId(1),
26546 "agent 'claude' does not support transport Acp".to_owned(),
26547 true,
26548 );
26549 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26551
26552 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26553 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26554 assert!(reason.unsupported_transport);
26555
26556 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26558
26559 shared.record_rejected_command(
26564 CommandId(3),
26565 "backend busy".to_owned(),
26566 false,
26567 );
26568 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26569 assert!(!unclassified.unsupported_transport);
26570 }
26571
26572 #[test]
26573 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26574 let catalog = active_registry().unwrap();
26575 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26576 let workspace = WorkspaceConfig::new(
26577 WorkspaceId::new("primary").unwrap(),
26578 std::env::current_dir().unwrap(),
26579 )
26580 .unwrap();
26581 let shared = NodeShared::new(
26582 handle,
26583 "fixture-token".to_owned(),
26584 NodeId::new("node-identity-policy").unwrap(),
26585 vec![workspace.clone()],
26586 vec![agent("claude")],
26587 );
26588 let address = SessionAddress {
26589 workspace_id: workspace.workspace_id.clone(),
26590 session: SessionKey {
26591 instance_id: AgentInstanceId(90),
26592 generation: SessionGeneration(1),
26593 },
26594 };
26595 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26596 shared
26597 .insert_record(ManagedSessionRecord {
26598 record_id: record_id.clone(),
26599 display_name: "policy denied".to_owned(),
26600 provider: agent("claude"),
26601 mode: SessionMode::Pty,
26602 state: ManagedSessionState::IdentityPending,
26603 workspace_id: workspace.workspace_id,
26604 canonical_root: opaque_windows_path(workspace.canonical_root),
26605 provider_session: None,
26606 active_session: Some(address.clone()),
26607 environment_profile: None,
26608 bundle: None,
26609 context_id: None,
26610 context: None,
26611 exported_context: None,
26612 task_binding: None,
26613 created_at_unix_ms: 1,
26614 updated_at_unix_ms: 1,
26615 last_error: None,
26616 })
26617 .unwrap();
26618 let event = ControlEvent {
26619 sequence: 1,
26620 command_id: None,
26621 instance_id: address.session.instance_id,
26622 generation: address.session.generation,
26623 event: ControlEventKind::ProviderEvent {
26624 sequence: 1,
26625 source: gate4agent_types::ProviderSource {
26626 family: AdapterFamily::PtySemantic,
26627 binding: AdapterBinding::new(
26628 gate4agent_types::AdapterId::new("claude").unwrap(),
26629 "fixture/v1",
26630 gate4agent_types::AdapterVerification::SyntheticFixture,
26631 )
26632 .unwrap(),
26633 },
26634 source_sequence: 1,
26635 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26636 identity: gate4agent_types::ProviderSessionIdentity {
26637 key: gate4agent_types::ProviderSessionKey::SessionId,
26638 id: "provider-session".to_owned(),
26639 transcript_path: None,
26640 },
26641 },
26642 },
26643 };
26644
26645 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26646
26647 let record = shared.record(&record_id).unwrap();
26648 assert!(record.provider_session.is_none());
26649 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26650 assert_eq!(record.active_session.as_ref(), Some(&address));
26651 }
26652
26653 #[test]
26654 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26655 let secondary_root = temporary_workspace_root("identity-scope");
26656 std::fs::create_dir_all(&secondary_root).unwrap();
26657 let catalog = active_registry().unwrap();
26658 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26659 let primary = WorkspaceConfig::new(
26660 WorkspaceId::new("primary").unwrap(),
26661 std::env::current_dir().unwrap(),
26662 )
26663 .unwrap();
26664 let secondary = WorkspaceConfig::new(
26665 WorkspaceId::new("secondary").unwrap(),
26666 &secondary_root,
26667 )
26668 .unwrap();
26669 let shared = NodeShared::new(
26670 handle,
26671 "fixture-token".to_owned(),
26672 NodeId::new("node-1").unwrap(),
26673 vec![primary.clone(), secondary.clone()],
26674 vec![agent("claude")],
26675 );
26676 let identity = gate4agent_types::ProviderSessionIdentity {
26677 key: gate4agent_types::ProviderSessionKey::SessionId,
26678 id: "provider-session".to_owned(),
26679 transcript_path: None,
26680 };
26681 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26682 shared
26683 .insert_record(ManagedSessionRecord {
26684 record_id: dormant_id.clone(),
26685 display_name: "dormant".to_owned(),
26686 provider: agent("claude"),
26687 mode: SessionMode::Pty,
26688 state: ManagedSessionState::Dormant,
26689 workspace_id: primary.workspace_id,
26690 canonical_root: opaque_windows_path(primary.canonical_root),
26691 provider_session: Some(identity.clone()),
26692 active_session: None,
26693 environment_profile: None,
26694 bundle: None,
26695 context_id: None,
26696 context: None,
26697 exported_context: None,
26698 task_binding: None,
26699 created_at_unix_ms: 1,
26700 updated_at_unix_ms: 1,
26701 last_error: None,
26702 })
26703 .unwrap();
26704 let address = SessionAddress {
26705 workspace_id: secondary.workspace_id.clone(),
26706 session: SessionKey {
26707 instance_id: AgentInstanceId(91),
26708 generation: SessionGeneration(1),
26709 },
26710 };
26711 let current_id = SessionRecordId::new("sr-current").unwrap();
26712 shared
26713 .insert_record(ManagedSessionRecord {
26714 record_id: current_id.clone(),
26715 display_name: "current".to_owned(),
26716 provider: agent("claude"),
26717 mode: SessionMode::Pty,
26718 state: ManagedSessionState::IdentityPending,
26719 workspace_id: secondary.workspace_id,
26720 canonical_root: opaque_windows_path(secondary.canonical_root),
26721 provider_session: None,
26722 active_session: Some(address.clone()),
26723 environment_profile: None,
26724 bundle: None,
26725 context_id: None,
26726 context: None,
26727 exported_context: None,
26728 task_binding: None,
26729 created_at_unix_ms: 2,
26730 updated_at_unix_ms: 2,
26731 last_error: None,
26732 })
26733 .unwrap();
26734 shared.bind_managed_session(
26735 &address,
26736 current_id.clone(),
26737 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26738 None,
26739 );
26740 let event = ControlEvent {
26741 sequence: 1,
26742 command_id: None,
26743 instance_id: address.session.instance_id,
26744 generation: address.session.generation,
26745 event: ControlEventKind::ProviderEvent {
26746 sequence: 1,
26747 source: gate4agent_types::ProviderSource {
26748 family: gate4agent_types::AdapterFamily::PtySemantic,
26749 binding: gate4agent_types::AdapterBinding::new(
26750 gate4agent_types::AdapterId::new("claude").unwrap(),
26751 "fixture/v1",
26752 gate4agent_types::AdapterVerification::SyntheticFixture,
26753 )
26754 .unwrap(),
26755 },
26756 source_sequence: 1,
26757 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26758 identity,
26759 },
26760 },
26761 };
26762 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26763
26764 let binding = shared
26765 .session_bindings
26766 .lock()
26767 .unwrap_or_else(|poisoned| poisoned.into_inner())
26768 .get(&address.session.instance_id)
26769 .cloned()
26770 .unwrap();
26771 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26772 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26773 let current = shared.record(¤t_id).unwrap();
26774 assert_eq!(current.state, ManagedSessionState::Unavailable);
26775 assert_eq!(current.active_session.as_ref(), Some(&address));
26776 assert_eq!(
26777 current.last_error.as_deref(),
26778 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26779 );
26780 std::fs::remove_dir_all(secondary_root).unwrap();
26781 }
26782
26783 #[tokio::test]
26784 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26785 let root = temporary_workspace_root("semantic-provider-session");
26786 std::fs::create_dir_all(&root).unwrap();
26787 let state_path = root.join("state-v1.json");
26788 let catalog = active_registry().unwrap();
26789 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26790 let workspace = WorkspaceConfig::new(
26791 WorkspaceId::new("primary").unwrap(),
26792 &root,
26793 )
26794 .unwrap();
26795 let node_id = NodeId::new("node-semantic-identity").unwrap();
26796 let shared = NodeShared::new_with_incarnation(
26797 handle,
26798 "fixture-token".to_owned(),
26799 node_id.clone(),
26800 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26801 vec![workspace.clone()],
26802 vec![agent("claude")],
26803 ProviderRuntimeStatuses::default(),
26804 None,
26805 Vec::new(),
26806 Vec::new(),
26807 SpawnProfileRegistry::default(),
26808 None,
26809 None,
26810 None,
26811 Some(state_path.clone()),
26812 Vec::new(),
26813 Vec::new(),
26814 Vec::new(),
26815 Vec::new(),
26816 None,
26817 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26818 );
26819 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26820 key: gate4agent_types::ProviderSessionKey::SessionId,
26821 id: "provider-session".to_owned(),
26822 transcript_path: None,
26823 };
26824 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26825 shared
26826 .insert_record(ManagedSessionRecord {
26827 record_id: durable_id.clone(),
26828 display_name: "durable".to_owned(),
26829 provider: agent("claude"),
26830 mode: SessionMode::Pty,
26831 state: ManagedSessionState::Dormant,
26832 workspace_id: workspace.workspace_id.clone(),
26833 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26834 provider_session: Some(persisted_identity.clone()),
26835 active_session: None,
26836 environment_profile: None,
26837 bundle: None,
26838 context_id: None,
26839 context: None,
26840 exported_context: None,
26841 task_binding: None,
26842 created_at_unix_ms: 1,
26843 updated_at_unix_ms: 1,
26844 last_error: None,
26845 })
26846 .unwrap();
26847 let address = SessionAddress {
26848 workspace_id: workspace.workspace_id.clone(),
26849 session: SessionKey {
26850 instance_id: AgentInstanceId(92),
26851 generation: SessionGeneration(2),
26852 },
26853 };
26854 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26855 shared
26856 .insert_record(ManagedSessionRecord {
26857 record_id: pending_id.clone(),
26858 display_name: "pending".to_owned(),
26859 provider: agent("claude"),
26860 mode: SessionMode::Pty,
26861 state: ManagedSessionState::IdentityPending,
26862 workspace_id: workspace.workspace_id.clone(),
26863 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26864 provider_session: None,
26865 active_session: Some(address.clone()),
26866 environment_profile: None,
26867 bundle: None,
26868 context_id: None,
26869 context: None,
26870 exported_context: None,
26871 task_binding: None,
26872 created_at_unix_ms: 2,
26873 updated_at_unix_ms: 2,
26874 last_error: None,
26875 })
26876 .unwrap();
26877 shared.bind_managed_session(
26878 &address,
26879 pending_id.clone(),
26880 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26881 None,
26882 );
26883 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26884 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26885 ..persisted_identity
26886 };
26887 let event = ControlEvent {
26888 sequence: 1,
26889 command_id: None,
26890 instance_id: address.session.instance_id,
26891 generation: address.session.generation,
26892 event: ControlEventKind::ProviderEvent {
26893 sequence: 1,
26894 source: gate4agent_types::ProviderSource {
26895 family: gate4agent_types::AdapterFamily::PtySemantic,
26896 binding: gate4agent_types::AdapterBinding::new(
26897 gate4agent_types::AdapterId::new("claude").unwrap(),
26898 "fixture/v1",
26899 gate4agent_types::AdapterVerification::SyntheticFixture,
26900 )
26901 .unwrap(),
26902 },
26903 source_sequence: 1,
26904 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26905 identity: observed_identity.clone(),
26906 },
26907 },
26908 };
26909 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26910
26911 let snapshot = shared.snapshot();
26912 assert_eq!(snapshot.session_records.len(), 1);
26913 let live = &snapshot.session_records[0];
26914 assert_eq!(live.record_id, durable_id);
26915 assert_eq!(live.state, ManagedSessionState::Live);
26916 assert_eq!(live.active_session.as_ref(), Some(&address));
26917 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26918 let binding = shared
26919 .session_bindings
26920 .lock()
26921 .unwrap_or_else(|poisoned| poisoned.into_inner())
26922 .get(&address.session.instance_id)
26923 .cloned()
26924 .unwrap();
26925 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26926
26927 let mut duplicate = live.clone();
26928 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26929 duplicate.display_name = "duplicate".to_owned();
26930 duplicate.state = ManagedSessionState::Dormant;
26931 duplicate.active_session = None;
26932 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26933 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26934 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26935 assert_eq!(shared.snapshot().session_records.len(), 1);
26936
26937 let terminal_size = gate4agent_types::TerminalSize {
26938 rows: 24,
26939 columns: 80,
26940 };
26941 let (first_busy, second_busy) = tokio::join!(
26942 shared.resume_session_record(&durable_id, terminal_size, None),
26943 shared.resume_session_record(&durable_id, terminal_size, None),
26944 );
26945 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26946 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26947 assert_eq!(busy.message, "session-record-busy");
26948 }
26949
26950 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26951 assert_eq!(loaded.records.len(), 1);
26952 assert_eq!(loaded.records[0].record_id, durable_id);
26953 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26954 assert_eq!(
26955 loaded.records[0]
26956 .provider_session
26957 .as_ref()
26958 .unwrap()
26959 .transcript_path,
26960 None,
26961 );
26962 let catalog = active_registry().unwrap();
26963 let (reloaded_handle, _reloaded_runtime) =
26964 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26965 let reloaded = NodeShared::new_with_incarnation(
26966 reloaded_handle,
26967 "fixture-token".to_owned(),
26968 node_id,
26969 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26970 vec![workspace],
26971 vec![agent("claude")],
26972 ProviderRuntimeStatuses::default(),
26973 None,
26974 Vec::new(),
26975 Vec::new(),
26976 SpawnProfileRegistry::default(),
26977 None,
26978 None,
26979 None,
26980 None,
26981 loaded.records,
26982 loaded.managed_worktrees,
26983 loaded.managed_worktree_tombstones,
26984 loaded.materializations,
26985 loaded.warning,
26986 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26987 );
26988 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26989 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26990 std::fs::remove_dir_all(root).unwrap();
26991 }
26992
26993 #[tokio::test]
26994 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26995 let secondary_root = temporary_workspace_root("workspace-restore");
26996 let other_root = temporary_workspace_root("workspace-conflict");
26997 std::fs::create_dir_all(&secondary_root).unwrap();
26998 std::fs::create_dir_all(&other_root).unwrap();
26999 let catalog = active_registry().unwrap();
27000 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27001 let primary = WorkspaceConfig::new(
27002 WorkspaceId::new("primary").unwrap(),
27003 std::env::current_dir().unwrap(),
27004 )
27005 .unwrap();
27006 let secondary = WorkspaceConfig::new(
27007 WorkspaceId::new("secondary").unwrap(),
27008 &secondary_root,
27009 )
27010 .unwrap();
27011 let shared = NodeShared::new(
27012 handle,
27013 "fixture-token".to_owned(),
27014 NodeId::new("node-1").unwrap(),
27015 vec![primary, secondary.clone()],
27016 vec![agent("claude")],
27017 );
27018 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27019 shared
27020 .insert_record(ManagedSessionRecord {
27021 record_id: record_id.clone(),
27022 display_name: "workspace record".to_owned(),
27023 provider: agent("claude"),
27024 mode: SessionMode::Pty,
27025 state: ManagedSessionState::Dormant,
27026 workspace_id: secondary.workspace_id.clone(),
27027 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27028 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27029 key: gate4agent_types::ProviderSessionKey::SessionId,
27030 id: "provider-session".to_owned(),
27031 transcript_path: None,
27032 }),
27033 active_session: None,
27034 environment_profile: None,
27035 bundle: None,
27036 context_id: None,
27037 context: None,
27038 exported_context: None,
27039 task_binding: None,
27040 created_at_unix_ms: 1,
27041 updated_at_unix_ms: 1,
27042 last_error: None,
27043 })
27044 .unwrap();
27045 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27046 let unavailable = shared.record(&record_id).unwrap();
27047 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27048 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27049
27050 let conflict = shared
27051 .register_workspace(
27052 secondary.workspace_id.clone(),
27053 other_root.to_string_lossy().into_owned(),
27054 )
27055 .await
27056 .unwrap_err();
27057 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27058 shared
27059 .register_workspace(
27060 secondary.workspace_id,
27061 secondary_root.to_string_lossy().into_owned(),
27062 )
27063 .await
27064 .unwrap();
27065 let restored = shared.record(&record_id).unwrap();
27066 assert_eq!(restored.state, ManagedSessionState::Dormant);
27067 assert!(restored.last_error.is_none());
27068 std::fs::remove_dir_all(secondary_root).unwrap();
27069 std::fs::remove_dir_all(other_root).unwrap();
27070 }
27071
27072 #[test]
27073 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27074 let error = validate_workspace_request_root(
27075 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27076 )
27077 .unwrap_err();
27078 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27079 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27080 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27081 }
27082
27083 #[test]
27084 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27085 let catalog = active_registry().unwrap();
27086 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27087 let workspace = WorkspaceConfig::new(
27088 WorkspaceId::new("primary").unwrap(),
27089 std::env::current_dir().unwrap(),
27090 )
27091 .unwrap();
27092 let shared = NodeShared::new(
27093 handle,
27094 "fixture-token".to_owned(),
27095 NodeId::new("node-1").unwrap(),
27096 vec![workspace],
27097 vec![agent("claude")],
27098 );
27099 let publishers = 4_u64;
27100 let events_per_publisher = 256_u64;
27101 let start = std::sync::Barrier::new(publishers as usize + 1);
27102 let finished = AtomicU64::new(0);
27103
27104 std::thread::scope(|scope| {
27105 for _ in 0..publishers {
27106 scope.spawn(|| {
27107 start.wait();
27108 for _ in 0..events_per_publisher {
27109 shared.publish(NodeEvent::ControllerChanged { controller: None });
27110 }
27111 finished.fetch_add(1, Ordering::Release);
27112 });
27113 }
27114 start.wait();
27115 while finished.load(Ordering::Acquire) != publishers {
27116 assert_resync_history_is_linearized(shared.resync(0));
27117 std::thread::yield_now();
27118 }
27119 });
27120 let response = shared.resync(0);
27121 assert_resync_history_is_linearized(response.clone());
27122 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27123 unreachable!("resync helper returned another response");
27124 };
27125 assert_eq!(event_sequence, publishers * events_per_publisher);
27126 assert_eq!(events.len() as u64, event_sequence);
27127 }
27128
27129 fn assert_resync_history_is_linearized(response: NodeResponse) {
27130 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27131 unreachable!("resync helper returned another response");
27132 };
27133 assert!(events.windows(2).all(|pair| {
27134 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27135 }));
27136 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27137 }
27138
27139 #[test]
27140 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27141 assert_eq!(
27142 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27143 r"C:\repo\worktree",
27144 );
27145 assert_eq!(
27146 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27147 r"\\server\share\repo",
27148 );
27149 assert!(validate_workspace_root(
27150 &WorkspaceId::new("network").unwrap(),
27151 r"\\server\share\repo",
27152 )
27153 .is_err());
27154 let workspace = WorkspaceConfig::new(
27155 WorkspaceId::new("primary").unwrap(),
27156 std::env::current_dir().unwrap(),
27157 )
27158 .unwrap();
27159 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27160 }
27161
27162 #[test]
27163 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27164 let catalog = active_registry().unwrap();
27165 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27166 let workspace_id = WorkspaceId::new("primary").unwrap();
27167 let workspace = WorkspaceConfig::new(
27168 workspace_id.clone(),
27169 std::env::current_dir().unwrap(),
27170 )
27171 .unwrap();
27172 let shared = NodeShared::new(
27173 handle,
27174 "fixture-token".to_owned(),
27175 NodeId::new("node-1").unwrap(),
27176 vec![workspace],
27177 vec![agent("claude")],
27178 );
27179 let instance_id = AgentInstanceId(17);
27180 let original = SessionAddress {
27181 workspace_id,
27182 session: SessionKey {
27183 instance_id,
27184 generation: SessionGeneration(3),
27185 },
27186 };
27187 shared.bind_session(
27188 instance_id,
27189 original.workspace_id.clone(),
27190 original.session.generation,
27191 );
27192 shared
27193 .arm_resume(
27194 &original,
27195 CommandId(41),
27196 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27197 )
27198 .unwrap();
27199
27200 shared.publish_control(ControlEvent {
27201 sequence: 1,
27202 command_id: None,
27203 instance_id,
27204 generation: SessionGeneration(4),
27205 event: ControlEventKind::Running { process_id: None },
27206 });
27207 assert_eq!(
27208 shared.address_for(instance_id, SessionGeneration(3)),
27209 Some(original.clone()),
27210 );
27211 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27212
27213 shared.publish_control(ControlEvent {
27214 sequence: 2,
27215 command_id: Some(CommandId(42)),
27216 instance_id,
27217 generation: SessionGeneration(3),
27218 event: ControlEventKind::CommandRejected {
27219 message: "unrelated command".to_owned(),
27220 },
27221 });
27222
27223 shared.publish_control(ControlEvent {
27224 sequence: 3,
27225 command_id: None,
27226 instance_id,
27227 generation: SessionGeneration(4),
27228 event: ControlEventKind::ResumeAuthorized {
27229 session: gate4agent_types::ResumeSessionSummary {
27230 key: gate4agent_types::ProviderSessionKey::SessionId,
27231 id: "fixture-session".to_owned(),
27232 },
27233 },
27234 });
27235 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27236 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27237 assert_eq!(rebound.workspace_id, original.workspace_id);
27238 let history = shared
27239 .history
27240 .lock()
27241 .unwrap_or_else(|poisoned| poisoned.into_inner());
27242 assert!(history.events.iter().any(|envelope| matches!(
27243 &envelope.event,
27244 NodeEvent::Control { address, event }
27245 if address == &rebound
27246 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27247 )));
27248 }
27249
27250 #[test]
27251 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27252 for agent in ["claude", "codex", "kimi"] {
27253 assert_eq!(
27254 prompt_framing(&AgentId::new(agent).unwrap()),
27255 PromptFraming::BracketedPaste
27256 );
27257 }
27258 }
27259
27260 fn generous_walk_deadline() -> Instant {
27261 Instant::now() + Duration::from_secs(30)
27262 }
27263
27264 #[test]
27265 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27266 let root = temporary_workspace_root("tree");
27267 std::fs::create_dir_all(root.join("src")).unwrap();
27268 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27269 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27270 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27271 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27272 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27273 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27279 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27280 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27284 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27285
27286 let (entries, truncated, _budget) =
27287 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27288 assert!(!truncated);
27289 assert!(entries.iter().any(|entry| {
27290 entry.relative_path.as_utf8() == Some("src")
27291 && entry.kind == WorkspaceEntryKind::Directory
27292 }));
27293 assert!(entries.iter().any(|entry| {
27294 entry.relative_path.as_utf8() == Some("src/lib.rs")
27295 && entry.kind == WorkspaceEntryKind::File
27296 }));
27297 assert!(entries.iter().any(|entry| {
27298 entry.relative_path.as_utf8() == Some("target-notes")
27299 && entry.kind == WorkspaceEntryKind::Directory
27300 }));
27301 assert!(entries.iter().any(|entry| {
27302 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27303 && entry.kind == WorkspaceEntryKind::File
27304 }));
27305 assert!(entries.iter().all(|entry| {
27306 let path = entry.relative_path.as_utf8().unwrap();
27307 !path.starts_with(".git")
27308 && !(path == "target" || path.starts_with("target/"))
27309 && !(path == "target-a2" || path.starts_with("target-a2/"))
27310 && !path.starts_with("node_modules")
27311 }));
27312
27313 std::fs::remove_dir_all(&root).unwrap();
27314 }
27315
27316 #[test]
27317 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27318 let root = temporary_workspace_root("tree-entry-cap");
27319 std::fs::create_dir_all(&root).unwrap();
27320 for index in 0..20 {
27321 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27322 }
27323
27324 let (entries, truncated, budget) =
27325 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27326 assert!(truncated);
27327 assert!(budget.entry_cap_exceeded);
27328 assert!(!budget.time_budget_exceeded);
27329 assert!(entries.len() <= 5);
27330
27331 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27332 .expect("an entry-cap-truncated walk must report a truncation marker");
27333 assert!(inspection_truncation.walk_entry_cap_exceeded);
27334 assert!(!inspection_truncation.walk_time_budget_exceeded);
27335 assert!(!inspection_truncation.git_time_budget_exceeded);
27336 assert!(inspection_truncation.entries_visited >= 5);
27337
27338 std::fs::remove_dir_all(&root).unwrap();
27339 }
27340
27341 #[test]
27342 fn workspace_walk_within_budget_reports_no_truncation() {
27343 let root = temporary_workspace_root("tree-no-truncation");
27344 std::fs::create_dir_all(&root).unwrap();
27345 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27346
27347 let (_entries, truncated, budget) =
27348 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27349 assert!(!truncated);
27350 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27351
27352 std::fs::remove_dir_all(&root).unwrap();
27353 }
27354
27355 #[test]
27356 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27357 let root = temporary_workspace_root("tree-prefix-siblings");
27358 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27359 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27360 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27361 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27362
27363 let (entries, truncated, _budget) =
27364 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27365 assert!(!truncated);
27366 let paths = entries
27367 .iter()
27368 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27369 .collect::<Vec<_>>();
27370 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27371 assert_eq!(
27372 paths,
27373 vec![
27374 "gate4agent-c2".to_owned(),
27375 "gate4agent-c2-client".to_owned(),
27376 "gate4agent-c2-client/lib.rs".to_owned(),
27377 "gate4agent-c2/src".to_owned(),
27378 "gate4agent-c2/src/lib.rs".to_owned(),
27379 ],
27380 );
27381
27382 std::fs::remove_dir_all(&root).unwrap();
27383 }
27384
27385 #[test]
27386 fn git_parsers_cap_status_and_commit_results() {
27387 let mut status_output = Vec::new();
27388 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27389 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27390 }
27391 let mut snapshot = git_snapshot_for_parser();
27392 parse_git_status(&status_output, &mut snapshot);
27393 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27394 assert!(snapshot.truncated);
27395 assert_eq!(snapshot.status[0].index_status, " ");
27396 assert_eq!(snapshot.status[0].worktree_status, "M");
27397
27398 snapshot.truncated = false;
27399 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27400 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27401 .collect::<String>();
27402 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27403 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27404 assert!(snapshot.truncated);
27405 }
27406
27407 #[test]
27408 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27409 let mut snapshot = git_snapshot_for_parser();
27410 parse_git_status(
27411 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27412 &mut snapshot,
27413 );
27414
27415 assert!(!snapshot.truncated);
27416 assert_eq!(snapshot.status.len(), 3);
27417 assert_eq!(snapshot.status[0].index_status, "R");
27418 assert_eq!(snapshot.status[0].worktree_status, " ");
27419 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27420 assert_eq!(
27421 snapshot.status[0]
27422 .previous_path
27423 .as_ref()
27424 .and_then(RepositoryPath::as_utf8),
27425 Some("src/previous name.rs"),
27426 );
27427 assert_eq!(snapshot.status[1].index_status, " ");
27428 assert_eq!(snapshot.status[1].worktree_status, "C");
27429 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27430 assert_eq!(
27431 snapshot.status[1]
27432 .previous_path
27433 .as_ref()
27434 .and_then(RepositoryPath::as_utf8),
27435 Some("src/original.rs"),
27436 );
27437 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27438 assert!(snapshot.status[2].previous_path.is_none());
27439 }
27440
27441 #[test]
27442 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27443 let mut snapshot = git_snapshot_for_parser();
27444 parse_git_status(
27445 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27446 &mut snapshot,
27447 );
27448
27449 assert!(snapshot.truncated);
27450 assert_eq!(snapshot.status.len(), 1);
27451 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27452 assert!(snapshot.status[0].previous_path.is_none());
27453 }
27454
27455 #[tokio::test]
27456 async fn observer_can_browse_host_directories_without_controller() {
27457 let root = temporary_workspace_root("host-directory-observer");
27458 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27459 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27460 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27461 let workspace_id = WorkspaceId::new("primary").unwrap();
27462 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27463 let catalog = active_registry().unwrap();
27464 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27465 let shared = NodeShared::new(
27466 handle,
27467 "fixture-token".to_owned(),
27468 NodeId::new("node-1").unwrap(),
27469 vec![workspace],
27470 vec![agent("claude")],
27471 );
27472 let request = NodeRequest::BrowseHostDirectories {
27473 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27474 after: None,
27475 };
27476 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27477 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27478 }));
27479 assert!(!request_uses_unnegotiated_capability(
27480 &request,
27481 &baseline_capabilities().unwrap(),
27482 ));
27483 assert!(request_uses_unnegotiated_capability(&request, &[]));
27484
27485 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27486 .await
27487 .unwrap();
27488 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27489 panic!("host directory browse returned another response");
27490 };
27491 assert_eq!(
27492 listing
27493 .entries
27494 .iter()
27495 .map(|entry| entry.display_name.as_str())
27496 .collect::<Vec<_>>(),
27497 vec!["directory-a", "directory-b"],
27498 );
27499 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27500 assert!(!listing.incomplete);
27501 std::fs::remove_dir_all(root).unwrap();
27502 }
27503
27504 #[tokio::test]
27505 async fn observer_can_inspect_only_a_registered_workspace() {
27506 let root = temporary_workspace_root("observer");
27507 std::fs::create_dir_all(root.join("src")).unwrap();
27508 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27509 let workspace_id = WorkspaceId::new("primary").unwrap();
27510 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27511 let catalog = active_registry().unwrap();
27512 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27513 let shared = NodeShared::new(
27514 handle,
27515 "fixture-token".to_owned(),
27516 NodeId::new("node-1").unwrap(),
27517 vec![workspace],
27518 vec![agent("claude")],
27519 );
27520
27521 let response = process_request_inner(
27522 &shared,
27523 77,
27524 ClientRole::Observer,
27525 NodeRequest::InspectWorkspace {
27526 workspace_id: workspace_id.clone(),
27527 },
27528 )
27529 .await
27530 .unwrap();
27531 let NodeResponse::WorkspaceInspected { inspection } = response else {
27532 panic!("workspace inspection returned another response");
27533 };
27534 assert_eq!(inspection.workspace_id, workspace_id);
27535 assert!(inspection.entries.iter().any(|entry| {
27536 entry.relative_path.as_utf8() == Some("src/main.rs")
27537 }));
27538
27539 let unknown = process_request_inner(
27540 &shared,
27541 77,
27542 ClientRole::Observer,
27543 NodeRequest::InspectWorkspace {
27544 workspace_id: WorkspaceId::new("unknown").unwrap(),
27545 },
27546 )
27547 .await
27548 .unwrap_err();
27549 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27550 std::fs::remove_dir_all(&root).unwrap();
27551 }
27552
27553 #[test]
27554 fn context_pack_source_accepts_only_running_or_successful_exit() {
27555 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27556 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27557 exit_code: Some(0),
27558 }));
27559 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27560 exit_code: Some(1),
27561 }));
27562 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27563 exit_code: None,
27564 }));
27565 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27566 message: "provider failed".to_owned(),
27567 }));
27568 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27569 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27570 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27571 }
27572
27573 #[test]
27574 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27575 let git = git_snapshot_for_parser();
27576 let first = ContextPackRepositoryObservation {
27577 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27578 git: git.clone(),
27579 files: Vec::new(),
27580 };
27581 let second = ContextPackRepositoryObservation {
27582 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27583 git,
27584 files: Vec::new(),
27585 };
27586
27587 let error = stable_context_pack_repository(first, second).unwrap_err();
27588
27589 assert_eq!(
27590 error.code,
27591 NodeFailureCode::ContextPackMaterializationFailed,
27592 );
27593 }
27594
27595 #[test]
27596 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27597 let git = git_snapshot_for_parser();
27598 let first = ContextPackRepositoryObservation {
27599 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27600 git: git.clone(),
27601 files: vec![ContextPackRepositoryFileSource::utf8(
27602 "README.md",
27603 "first\n".to_owned(),
27604 6,
27605 )],
27606 };
27607 let second = ContextPackRepositoryObservation {
27608 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27609 git,
27610 files: vec![ContextPackRepositoryFileSource::utf8(
27611 "README.md",
27612 "other\n".to_owned(),
27613 6,
27614 )],
27615 };
27616
27617 let error = stable_context_pack_repository(first, second).unwrap_err();
27618
27619 assert_eq!(
27620 error.code,
27621 NodeFailureCode::ContextPackMaterializationFailed,
27622 );
27623 }
27624
27625 #[tokio::test]
27626 async fn native_session_catalog_zero_active_session_fixture() {
27627 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27628 let history_root = temporary_workspace_root("native-catalog-history");
27629 let outside_root = temporary_workspace_root("native-catalog-outside");
27630 std::fs::create_dir_all(&workspace_root).unwrap();
27631 std::fs::create_dir_all(&history_root).unwrap();
27632 std::fs::create_dir_all(&outside_root).unwrap();
27633 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27634 [
27635 serde_json::json!({
27636 "type": "user",
27637 "sessionId": session_id,
27638 "cwd": cwd.to_str().unwrap(),
27639 "message": { "content": "raw user text must not cross the catalog wire" }
27640 }),
27641 serde_json::json!({
27642 "type": "assistant",
27643 "sessionId": session_id,
27644 "cwd": cwd.to_str().unwrap(),
27645 "message": { "model": "claude-sonnet", "content": "raw answer" }
27646 }),
27647 serde_json::json!({
27648 "type": "ai-title",
27649 "sessionId": session_id,
27650 "aiTitle": title
27651 }),
27652 ]
27653 .into_iter()
27654 .map(|record| record.to_string())
27655 .collect::<Vec<_>>()
27656 .join("\n")
27657 };
27658 std::fs::write(
27659 history_root.join("stable-session.jsonl"),
27660 transcript("stable-session", &workspace_root, "Workspace review"),
27661 )
27662 .unwrap();
27663 std::fs::write(
27664 history_root.join("outside-session.jsonl"),
27665 transcript("outside-session", &outside_root, "Outside review"),
27666 )
27667 .unwrap();
27668 let history = NativeHistoryConfig::new(vec![
27669 NativeHistoryRoot::new(
27670 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27671 HistorySourceLayout::SingleNdjson,
27672 history_root.clone(),
27673 )
27674 .unwrap(),
27675 ])
27676 .unwrap();
27677 let catalog = active_registry().unwrap();
27678 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27679 assert!(handle.snapshot().sessions.is_empty());
27680 let workspace_id = WorkspaceId::new("primary").unwrap();
27681 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27682 let mut shared = NodeShared::new(
27683 handle,
27684 "fixture-token".to_owned(),
27685 NodeId::new("node-native-catalog").unwrap(),
27686 vec![workspace],
27687 vec![agent("claude")],
27688 );
27689 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27690 NativeSessionCatalogAuthority::new(history),
27691 )));
27692 let route = NativeSessionCatalogRoute::workspace(
27693 workspace_id.clone(),
27694 agent("claude"),
27695 );
27696 let request = NodeRequest::CatalogNativeSessions {
27697 route: route.clone(),
27698 limit: 2,
27699 };
27700 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27701 .await
27702 .unwrap_err();
27703 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27704 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27705 .await
27706 .unwrap();
27707 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27708 panic!("native catalog returned a different response");
27709 };
27710 let summary = summary.expect("node must emit native session catalog summary metadata");
27711 assert_eq!(entries.len(), 1);
27712 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27713 assert_eq!(entries[0].external_group, None);
27714 assert_eq!(entries[0].record_id, None);
27715 assert_eq!(entries[0].title, None);
27716 assert_eq!(entries[0].model, None);
27717 assert_eq!(entries[0].message_count, 0);
27718 let encoded_entries = serde_json::to_string(&entries).unwrap();
27719 assert!(!encoded_entries.contains("stable-session"));
27720 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27721 let page_request = NodeRequest::PageNativeSessions {
27722 route: route.clone(),
27723 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27724 catalog_revision: summary.catalog_revision,
27725 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27726 after_selection_id: None,
27727 limit: 2,
27728 };
27729 let observer = process_request_inner(
27730 &shared,
27731 7,
27732 ClientRole::Observer,
27733 page_request.clone(),
27734 )
27735 .await
27736 .unwrap_err();
27737 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27738 let page_response = process_request_inner(
27739 &shared,
27740 7,
27741 ClientRole::Operator,
27742 page_request.clone(),
27743 )
27744 .await
27745 .unwrap();
27746 assert!(matches!(
27747 page_response,
27748 NodeResponse::NativeSessionsPaged { page, .. }
27749 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27750 ));
27751 let stale = process_request_inner(
27752 &shared,
27753 7,
27754 ClientRole::Operator,
27755 NodeRequest::PageNativeSessions {
27756 route: route.clone(),
27757 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27758 catalog_revision: summary.catalog_revision.wrapping_add(1),
27759 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27760 after_selection_id: None,
27761 limit: 2,
27762 },
27763 )
27764 .await
27765 .unwrap_err();
27766 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27767 let selection = NativeSessionSelection {
27768 route: route.clone(),
27769 catalog_revision: summary.catalog_revision,
27770 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27771 selection_id: entries[0].selection_id.clone(),
27772 };
27773 let preview_response = process_request_inner(
27774 &shared,
27775 7,
27776 ClientRole::Operator,
27777 NodeRequest::PreviewNativeSession {
27778 selection: selection.clone(),
27779 message_limit: 1,
27780 },
27781 )
27782 .await
27783 .unwrap();
27784 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27785 panic!("native preview returned a different response");
27786 };
27787 assert_eq!(preview.messages.len(), 1);
27788 assert!(preview.message_count_exact);
27789 assert_eq!(preview.messages[0].text, "raw answer");
27790 assert!(preview.truncated);
27791
27792 let index_request = NodeRequest::IndexNativeSession {
27793 selection: selection.clone(),
27794 display_name: "Saved Claude".to_owned(),
27795 };
27796 let observer = process_request_inner(
27797 &shared,
27798 7,
27799 ClientRole::Observer,
27800 index_request.clone(),
27801 )
27802 .await
27803 .unwrap_err();
27804 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27805 let without_controller = process_request_inner(
27806 &shared,
27807 7,
27808 ClientRole::Operator,
27809 index_request.clone(),
27810 )
27811 .await
27812 .unwrap_err();
27813 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27814 let acquired = process_request_inner(
27815 &shared,
27816 7,
27817 ClientRole::Operator,
27818 NodeRequest::AcquireController { lease_ms: 5_000 },
27819 )
27820 .await
27821 .unwrap();
27822 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27823 let indexed = process_request_inner(
27824 &shared,
27825 7,
27826 ClientRole::Operator,
27827 index_request,
27828 )
27829 .await
27830 .unwrap();
27831 assert!(indexed.native_session_index_contract_is_valid());
27832 let NodeResponse::NativeSessionIndexed {
27833 selection: echoed_selection,
27834 record,
27835 } = indexed else {
27836 panic!("native session index returned a different response");
27837 };
27838 assert_eq!(echoed_selection, selection);
27839 let recataloged = process_request_inner(
27840 &shared,
27841 7,
27842 ClientRole::Operator,
27843 NodeRequest::CatalogNativeSessions {
27844 route,
27845 limit: 2,
27846 },
27847 )
27848 .await
27849 .unwrap();
27850 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27851 panic!("native catalog returned a different response after indexing");
27852 };
27853 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27854
27855 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27856 let external_catalog = process_request_inner(
27857 &shared,
27858 7,
27859 ClientRole::Operator,
27860 NodeRequest::CatalogNativeSessions {
27861 route: unregistered_route.clone(),
27862 limit: 2,
27863 },
27864 )
27865 .await
27866 .unwrap();
27867 let external_json = serde_json::to_string(&external_catalog).unwrap();
27868 assert!(!external_json.contains("outside-session"));
27869 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27870 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27871 assert!(!external_json.contains("\"session_id\""));
27872 assert!(!external_json.contains("\"cwd\""));
27873 assert!(!external_json.contains("\"path\""));
27874 let NodeResponse::NativeSessionsCataloged {
27875 route: echoed_route,
27876 entries: external_entries,
27877 summary: Some(external_summary),
27878 } = external_catalog
27879 else {
27880 panic!("unregistered native catalog returned a different response");
27881 };
27882 assert_eq!(echoed_route, unregistered_route);
27883 assert_eq!(external_entries.len(), 1);
27884 assert_eq!(external_entries[0].record_id, None);
27885 let external_group = external_entries[0]
27886 .external_group
27887 .as_ref()
27888 .expect("unregistered session must have an external group");
27889 assert_eq!(
27890 external_group.kind,
27891 gate4agent_types::NativeSessionExternalGroupKind::Project,
27892 );
27893 assert!(external_group.group_id.starts_with("external-"));
27894 assert_eq!(
27895 external_group.display_name,
27896 outside_root.file_name().unwrap().to_string_lossy(),
27897 );
27898 let external_selection = NativeSessionSelection {
27899 route: unregistered_route,
27900 catalog_revision: external_summary.catalog_revision,
27901 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27902 selection_id: external_entries[0].selection_id.clone(),
27903 };
27904 let external_preview = process_request_inner(
27905 &shared,
27906 7,
27907 ClientRole::Operator,
27908 NodeRequest::PreviewNativeSession {
27909 selection: external_selection.clone(),
27910 message_limit: 1,
27911 },
27912 )
27913 .await
27914 .unwrap();
27915 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27916 assert!(!external_preview_json.contains("outside-session"));
27917 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27918 assert!(!external_preview_json.contains("\"session_id\""));
27919 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27920 panic!("unregistered native preview returned a different response");
27921 };
27922 assert_eq!(preview.messages.len(), 1);
27923 let external_index = process_request_inner(
27924 &shared,
27925 7,
27926 ClientRole::Operator,
27927 NodeRequest::IndexNativeSession {
27928 selection: external_selection,
27929 display_name: "External Claude".to_owned(),
27930 },
27931 )
27932 .await
27933 .unwrap_err();
27934 assert_eq!(
27935 external_index.code,
27936 NodeFailureCode::WorkspaceRegistrationRequired,
27937 );
27938 let record_response = process_request_inner(
27939 &shared,
27940 7,
27941 ClientRole::Operator,
27942 NodeRequest::PreviewSessionRecord {
27943 record_id: record.record_id.clone(),
27944 message_limit: 2,
27945 },
27946 )
27947 .await
27948 .unwrap();
27949 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27950 panic!("record preview returned a different response");
27951 };
27952 assert_eq!(record_id, record.record_id);
27953 assert_eq!(preview.messages.len(), 2);
27954 let encoded = serde_json::to_string(&preview).unwrap();
27955 assert!(!encoded.contains("stable-session"));
27956 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27957 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27958 panic!("record preview history summary returned a different response");
27959 };
27960 let summaries = events
27961 .iter()
27962 .filter_map(|envelope| match &envelope.event {
27963 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27964 if record_id == &record.record_id =>
27965 {
27966 Some(*summary)
27967 }
27968 _ => None,
27969 })
27970 .collect::<Vec<_>>();
27971 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27972 let summary_json = serde_json::to_string(&summaries).unwrap();
27973 for private in [
27974 "stable-session",
27975 "raw user text must not cross the catalog wire",
27976 "raw answer",
27977 "Workspace review",
27978 "claude-sonnet",
27979 ] {
27980 assert!(!summary_json.contains(private), "{private}");
27981 }
27982 let identity = record.provider_session.clone().unwrap();
27983 assert_eq!(
27984 shared.revalidate_session_record_preview(&record, &identity),
27985 Ok(())
27986 );
27987 shared.remove_record_memory(&record.record_id);
27988 assert_eq!(
27989 shared
27990 .revalidate_session_record_preview(&record, &identity)
27991 .unwrap_err()
27992 .code,
27993 NodeFailureCode::UnknownSessionRecord
27994 );
27995 let released = process_request_inner(
27996 &shared,
27997 7,
27998 ClientRole::Operator,
27999 NodeRequest::ReleaseController,
28000 )
28001 .await
28002 .unwrap();
28003 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28004 assert!(shared.controller_state().is_none());
28005 std::fs::remove_dir_all(workspace_root).unwrap();
28006 std::fs::remove_dir_all(history_root).unwrap();
28007 std::fs::remove_dir_all(outside_root).unwrap();
28008 }
28009
28010 #[tokio::test]
28011 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28012 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28013 std::fs::create_dir_all(&history_root).unwrap();
28014 let history = NativeHistoryConfig::new(vec![
28015 NativeHistoryRoot::new(
28016 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28017 HistorySourceLayout::SingleNdjson,
28018 history_root.clone(),
28019 )
28020 .unwrap(),
28021 ])
28022 .unwrap();
28023 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28024 let held = catalog.lock().unwrap();
28025 let result = timeout(
28026 Duration::from_secs(1),
28027 run_native_session_catalog_operation(
28028 Arc::clone(&catalog),
28029 "native session catalog",
28030 |_| Ok::<(), std::convert::Infallible>(()),
28031 ),
28032 )
28033 .await
28034 .expect("concurrent native catalog request must not wait for the operation deadline")
28035 .unwrap_err();
28036 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28037 drop(held);
28038 std::fs::remove_dir_all(history_root).unwrap();
28039 }
28040
28041 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28042 let unique = std::time::SystemTime::now()
28043 .duration_since(std::time::UNIX_EPOCH)
28044 .unwrap()
28045 .as_nanos();
28046 std::env::temp_dir().join(format!(
28047 "gate4agent-node-{label}-{}-{unique}",
28048 std::process::id(),
28049 ))
28050 }
28051
28052 fn git_snapshot_for_parser() -> GitSnapshot {
28053 GitSnapshot {
28054 is_repository: true,
28055 branch: Some("main".to_owned()),
28056 status: Vec::new(),
28057 recent_commits: Vec::new(),
28058 worktrees: Vec::new(),
28059 managed_worktree: None,
28060 truncated: false,
28061 diagnostic: None,
28062 }
28063 }
28064
28065 #[tokio::test]
28066 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28067 let root = temporary_workspace_root("managed-git-scope");
28068 std::fs::create_dir_all(&root).unwrap();
28069 run_test_git(&root, &["init"]);
28070 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28071 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28072 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28073 run_test_git(&root, &["add", "seed.txt"]);
28074 run_test_git(&root, &["commit", "-m", "seed"]);
28075 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28076
28077 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28078 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28079 let catalog = active_registry().unwrap();
28080 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28081 let shared = NodeShared::new(
28082 handle,
28083 "fixture-token".to_owned(),
28084 NodeId::new("node-1").unwrap(),
28085 vec![workspace],
28086 vec![agent("claude")],
28087 );
28088 let mut lease = ManagedWorktreeLeaseRecord {
28089 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28090 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28091 workspace_id: workspace_id.clone(),
28092 profile_id: WorktreeProfileId::new("default").unwrap(),
28093 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28094 target_root: root.to_string_lossy().into_owned(),
28095 branch: "gate4agent/a".to_owned(),
28096 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28097 expected_head: None,
28098 retention: ManagedWorktreeRetention::Retain,
28099 state: ManagedWorktreeLeaseState::InUse,
28100 session_holders: vec![ManagedWorktreeSessionHolder {
28101 incarnation_id: shared.incarnation_id,
28102 instance_id: AgentInstanceId(7),
28103 generation: SessionGeneration(1),
28104 }],
28105 record_holders: Vec::new(),
28106 cleanup_failure: None,
28107 created_at_unix_ms: 1,
28108 updated_at_unix_ms: 1,
28109 };
28110 *shared
28111 .managed_worktrees
28112 .lock()
28113 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28114 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28115
28116 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28117 let scope = inspection.git.managed_worktree.unwrap();
28118 assert_eq!(scope.lease_id, lease.lease_id);
28119 assert_eq!(scope.branch, "gate4agent/a");
28120 assert_eq!(scope.active_session_count, 1);
28121
28122 lease.branch = "gate4agent/b".to_owned();
28123 *shared
28124 .managed_worktrees
28125 .lock()
28126 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28127 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28128 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28129 assert!(mismatch.git.managed_worktree.is_none());
28130 std::fs::remove_dir_all(root).unwrap();
28131 }
28132
28133 #[tokio::test]
28134 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28135 let root = temporary_workspace_root("workspace-file-write-controller");
28136 std::fs::create_dir_all(root.join("src")).unwrap();
28137 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28138 let workspace_id = WorkspaceId::new("primary").unwrap();
28139 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28140 let catalog = active_registry().unwrap();
28141 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28142 let shared = NodeShared::new(
28143 handle,
28144 "fixture-token".to_owned(),
28145 NodeId::new("node-1").unwrap(),
28146 vec![workspace],
28147 vec![agent("claude")],
28148 );
28149 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28150 let expected_revision = workspace_file_revision(b"old\n");
28151 let request = NodeRequest::WriteWorkspaceFile {
28152 workspace_id: workspace_id.clone(),
28153 path: path.clone(),
28154 expected_revision: expected_revision.clone(),
28155 text: "new\n".to_owned(),
28156 };
28157 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28158 .await
28159 .unwrap_err();
28160 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28161 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28162 .await
28163 .unwrap_err();
28164 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28165 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28166 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28167 let NodeResponse::WorkspaceFileWritten { file } = response else {
28168 panic!("workspace file write returned another response");
28169 };
28170 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28171 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28172 let stale = process_request_inner(
28173 &shared,
28174 77,
28175 ClientRole::Operator,
28176 NodeRequest::WriteWorkspaceFile {
28177 workspace_id,
28178 path,
28179 expected_revision,
28180 text: "lost update\n".to_owned(),
28181 },
28182 )
28183 .await
28184 .unwrap_err();
28185 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28186 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28187 std::fs::remove_dir_all(root).unwrap();
28188 }
28189
28190 #[tokio::test]
28191 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28192 let root = temporary_workspace_root("workspace-entry-create-controller");
28193 std::fs::create_dir_all(root.join("src")).unwrap();
28194 let workspace_id = WorkspaceId::new("primary").unwrap();
28195 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28196 let catalog = active_registry().unwrap();
28197 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28198 let shared = NodeShared::new(
28199 handle,
28200 "fixture-token".to_owned(),
28201 NodeId::new("node-1").unwrap(),
28202 vec![workspace],
28203 vec![agent("claude")],
28204 );
28205 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28206 let request = NodeRequest::CreateWorkspaceFile {
28207 workspace_id: workspace_id.clone(),
28208 path: path.clone(),
28209 };
28210 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28211 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28212 }));
28213 assert!(!request_uses_unnegotiated_capability(
28214 &request,
28215 &baseline_capabilities().unwrap(),
28216 ));
28217 assert!(request_uses_unnegotiated_capability(&request, &[]));
28218 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28219 .await
28220 .unwrap_err();
28221 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28222 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28223 .await
28224 .unwrap_err();
28225 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28226 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28227
28228 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28229 .await
28230 .unwrap();
28231 let NodeResponse::WorkspaceFileCreated { file } = response else {
28232 panic!("workspace file create returned another response");
28233 };
28234 assert_eq!(file.workspace_id, workspace_id);
28235 assert_eq!(file.path, path);
28236 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28237 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28238
28239 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28240 .await
28241 .unwrap_err();
28242 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28243
28244 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28245 let response = process_request_inner(
28246 &shared,
28247 77,
28248 ClientRole::Operator,
28249 NodeRequest::CreateWorkspaceDirectory {
28250 workspace_id: workspace_id.clone(),
28251 path: directory_path.clone(),
28252 },
28253 )
28254 .await
28255 .unwrap();
28256 assert_eq!(
28257 response,
28258 NodeResponse::WorkspaceDirectoryCreated {
28259 workspace_id: workspace_id.clone(),
28260 entry: WorkspaceEntry {
28261 relative_path: directory_path,
28262 kind: WorkspaceEntryKind::Directory,
28263 },
28264 },
28265 );
28266 assert!(root.join("src/nested").is_dir());
28267
28268 let missing_parent = process_request_inner(
28269 &shared,
28270 77,
28271 ClientRole::Operator,
28272 NodeRequest::CreateWorkspaceFile {
28273 workspace_id: workspace_id.clone(),
28274 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28275 },
28276 )
28277 .await
28278 .unwrap_err();
28279 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28280 let non_directory_parent = process_request_inner(
28281 &shared,
28282 77,
28283 ClientRole::Operator,
28284 NodeRequest::CreateWorkspaceFile {
28285 workspace_id,
28286 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28287 },
28288 )
28289 .await
28290 .unwrap_err();
28291 assert_eq!(
28292 non_directory_parent.code,
28293 NodeFailureCode::RepositoryParentNotDirectory,
28294 );
28295 std::fs::remove_dir_all(root).unwrap();
28296 }
28297
28298 #[tokio::test]
28299 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28300 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28301 std::fs::create_dir_all(root.join("src")).unwrap();
28302 #[cfg(windows)]
28303 let root = std::fs::canonicalize(root).unwrap();
28304 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28305 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28306 let barrier = Arc::new(std::sync::Barrier::new(2));
28307 let done = Arc::new(AtomicBool::new(false));
28308 let task_commit_state = Arc::clone(&commit_state);
28309 let task_barrier = Arc::clone(&barrier);
28310 let task_done = Arc::clone(&done);
28311 let task_root = root.clone();
28312 let task = tokio::task::spawn_blocking(move || {
28313 task_barrier.wait();
28314 let result = create_workspace_file_on_disk(
28315 &task_root,
28316 &path,
28317 &task_commit_state,
28318 );
28319 task_done.store(true, Ordering::Release);
28320 result
28321 });
28322
28323 let error = settle_workspace_entry_create(
28324 task,
28325 Arc::clone(&commit_state),
28326 Duration::from_millis(10),
28327 "fixture deadline",
28328 "fixture task failed",
28329 )
28330 .await
28331 .unwrap_err();
28332 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28333 assert_eq!(
28334 commit_state.load(Ordering::Acquire),
28335 WORKSPACE_ENTRY_CREATE_CANCELED,
28336 );
28337
28338 barrier.wait();
28339 timeout(Duration::from_secs(1), async {
28340 while !done.load(Ordering::Acquire) {
28341 tokio::task::yield_now().await;
28342 }
28343 })
28344 .await
28345 .expect("canceled blocking create did not settle");
28346 assert!(!root.join("src/late.rs").exists());
28347 std::fs::remove_dir_all(root).unwrap();
28348 }
28349}
28350
28351#[cfg(test)]
28352mod provisional_spawn_tests {
28353 use super::*;
28354 use std::sync::Arc;
28355
28356 fn shared() -> NodeShared {
28357 let catalog = active_registry().unwrap();
28358 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28359 let workspace = WorkspaceConfig::new(
28360 WorkspaceId::new("primary").unwrap(),
28361 std::env::current_dir().unwrap(),
28362 )
28363 .unwrap()
28364 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28365 NodeShared::new(
28366 handle,
28367 "fixture-token".to_owned(),
28368 NodeId::new("node-provisional-test").unwrap(),
28369 vec![workspace],
28370 vec![AgentId::new("claude").unwrap()],
28371 )
28372 }
28373
28374 #[test]
28377 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28378 let shared = shared();
28379 let ws = WorkspaceId::new("primary").unwrap();
28380 let mut ids = Vec::with_capacity(50);
28381 for i in 1u64..=50 {
28382 let id = AgentInstanceId(i);
28383 ids.push(id);
28384 let address = SessionAddress {
28385 workspace_id: ws.clone(),
28386 session: SessionKey {
28387 instance_id: id,
28388 generation: SessionGeneration(1),
28389 },
28390 };
28391 shared.mark_spawn_provisional(address);
28392 }
28393 assert_eq!(
28394 shared
28395 .provisional_spawns
28396 .lock()
28397 .unwrap_or_else(|p| p.into_inner())
28398 .len(),
28399 50
28400 );
28401 for id in ids.iter().step_by(2) {
28402 shared.sessionize_spawn(*id);
28403 }
28404 assert_eq!(
28405 shared
28406 .provisional_spawns
28407 .lock()
28408 .unwrap_or_else(|p| p.into_inner())
28409 .len(),
28410 25
28411 );
28412 assert!(shared.take_due_provisional_spawns().is_empty());
28413 {
28414 let mut map = shared
28415 .provisional_spawns
28416 .lock()
28417 .unwrap_or_else(|p| p.into_inner());
28418 let past = Instant::now() - Duration::from_secs(91);
28419 for (_, (_, at)) in map.iter_mut() {
28420 *at = past;
28421 }
28422 }
28423 let due = shared.take_due_provisional_spawns();
28424 assert_eq!(due.len(), 25);
28425 let due_ids: std::collections::BTreeSet<_> =
28426 due.iter().map(|a| a.session.instance_id.0).collect();
28427 let expected: std::collections::BTreeSet<_> =
28428 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28429 assert_eq!(due_ids, expected);
28430 assert!(shared
28431 .provisional_spawns
28432 .lock()
28433 .unwrap_or_else(|p| p.into_inner())
28434 .is_empty());
28435 }
28436
28437 #[test]
28440 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28441 let shared = Arc::new(shared());
28442 let ws = WorkspaceId::new("primary").unwrap();
28443 let mut handles = Vec::new();
28444 for i in 1u64..=20 {
28445 let shared = Arc::clone(&shared);
28446 let ws = ws.clone();
28447 handles.push(std::thread::spawn(move || {
28448 let id = AgentInstanceId(i);
28449 let address = SessionAddress {
28450 workspace_id: ws,
28451 session: SessionKey {
28452 instance_id: id,
28453 generation: SessionGeneration(1),
28454 },
28455 };
28456 shared.mark_spawn_provisional(address);
28457 id
28458 }));
28459 }
28460 let mut ids = Vec::new();
28461 for h in handles {
28462 ids.push(h.join().unwrap());
28463 }
28464 ids.sort_by_key(|id| id.0);
28465 assert_eq!(ids.len(), 20);
28466 ids.dedup();
28467 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28468 assert_eq!(
28469 shared
28470 .provisional_spawns
28471 .lock()
28472 .unwrap_or_else(|p| p.into_inner())
28473 .len(),
28474 20
28475 );
28476 shared.sessionize_spawn(AgentInstanceId(7));
28477 let left: Vec<_> = shared
28478 .provisional_spawns
28479 .lock()
28480 .unwrap_or_else(|p| p.into_inner())
28481 .keys()
28482 .map(|id| id.0)
28483 .collect();
28484 assert!(!left.contains(&7));
28485 assert_eq!(left.len(), 19);
28486 }
28487}
28488
28489
28490#[cfg(unix)]
28491extern "C" {
28492 #[link_name = "geteuid"]
28493 fn libc_geteuid() -> u32;
28494}
28495
28496#[cfg(test)]
28497mod call_home_link_tests {
28498 use super::*;
28499
28500 #[test]
28501 fn call_home_backoff_is_capped_and_monotonic() {
28502 let mut last = Duration::ZERO;
28503 for attempt in 1..20 {
28504 let next = call_home_backoff(attempt);
28505 assert!(next >= last);
28506 assert!(next <= Duration::from_secs(8));
28507 last = next;
28508 }
28509 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28510 }
28511
28512 #[tokio::test]
28513 async fn call_home_socket_gets_keepalive() {
28514 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28515 let addr = listener.local_addr().unwrap();
28516 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28517 let client = client.unwrap();
28518 enable_call_home_keepalive(&client).unwrap();
28519 let sock = socket2::SockRef::from(&client);
28520 assert!(sock.keepalive().unwrap());
28521 #[cfg(any(target_os = "linux", target_os = "macos"))]
28522 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28523 }
28524}
28525
28526fn provider_session_exists(provider: &str, id: &str) -> bool {
28528 if provider != "claude" { return false; }
28529 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28530 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28531 let name = format!("{id}.jsonl");
28532 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())))
28533}
28534
28535fn overlay_with_extra_args(
28537 provider: &AgentId,
28538 mode: SessionMode,
28539 overlay: Option<PreparedNativeLaunchOverlay>,
28540 args: Vec<OsString>,
28541) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28542 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28543 Ok(match (overlay, args) {
28544 (prepared, args) if args.is_empty() => prepared,
28545 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28546 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28547 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28548 provider.clone(),
28549 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28550 Vec::new(),
28551 args,
28552 ).map_err(|_| bad())?)),
28553 })
28554}