1use crate::git_worktree::{
2 create_worktree as create_git_worktree,
3 create_worktree_with_timeout as create_git_worktree_with_timeout,
4 list_worktrees as list_git_worktrees,
5 list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6 paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7 removal_lookup_path as normalize_worktree_removal_target,
8 resolve_base_commit_with_timeout,
9 run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10 NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13 browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16 EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22 bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25 ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26 ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29 HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30 ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33 MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34 MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35 SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38 exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39 ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42 self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43 ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44 MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48 create_workspace_directory as create_workspace_directory_on_disk,
49 create_workspace_file as create_workspace_file_on_disk,
50 read_workspace_file as read_workspace_file_from_disk,
51 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54 WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58 create_workspace_directory as create_workspace_directory_on_disk,
59 create_workspace_file as create_workspace_file_on_disk,
60 read_workspace_file as read_workspace_file_from_disk,
61 write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62 WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63 WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64 WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68 require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69 ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73 prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76 read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77 validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78 provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79 AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80 CapabilityId, ClientFrame, ClientRole,
81 ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82 DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83 AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84 AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85 GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86 GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87 GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88 HostDirectoryListing,
89 NodeCompatibilitySupport, NodeEvent,
90 NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91 NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92 ProtocolRange,
93 ProviderAdapterContractSupport,
94 ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95 ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96 ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97 ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98 ResolvedSpawnSpec, ResponseEnvelope,
99 ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100 ManagedSessionRecord,
101 ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102 SessionRecordRetentionConfig,
103 ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104 ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105 ManagedWorktreeSpawnRequestV2, SessionRecordId,
106 NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107 SessionTaskTargetV1, TaskId,
108 BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109 StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110 SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111 SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112 WorkspaceFileContent, WorkspaceFileRevision,
113 WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114 WorkspaceSnapshot,
115 WorktreeServiceMode,
116 LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117 WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118 DEFAULT_CONTROLLER_LEASE_MS,
119 MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122 NODE_PROVIDER_ID_OPEN_CAPABILITY,
123 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125 NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126 NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127 NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129 NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130 NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132 NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136 NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138 NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139 NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140 NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141 NODE_WORKTREE_SELECTION_CAPABILITY,
142 BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144 MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145 MAX_REPOSITORY_PATH_BYTES,
146 MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147 MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148 MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150 SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151 SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157 interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158 truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163 NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164 NativeLaunchEnvironmentOverlay,
165 NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166 NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167 ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176 auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177 AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180 AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219 static RESUME_PROVIDER_SESSION: Option<String>;
221 static SPAWN_ORIGIN: Option<String>;
223 static SPAWN_PARENT: Option<String>;
224}
225
226fn spawn_selection(
228 options: &gate4agent_node_protocol::SpawnSessionOptions,
229) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
230 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
231 match (&options.model, &options.effort) {
232 (None, None) => Ok(None),
233 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
234 (Some(model), effort) => {
235 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
236 if let Some(e) = effort {
237 sel = sel.with_value("effort", e.clone());
238 }
239 Ok(Some(sel))
240 }
241 }
242}
243
244mod browser_bridge;
245
246use gate4agent_node_core::drive_loop_profile;
247
248#[path = "bundle_delivery.rs"]
249mod bundle_delivery;
250
251#[path = "context_pack_store.rs"]
252mod context_pack_store;
253
254#[cfg(windows)]
255pub use crate::platform::DEFAULT_NODE_ENDPOINT;
256
257const NODE_EVENT_HISTORY_MAX: usize = 4_096;
258const NODE_BROADCAST_CAPACITY: usize = 1_024;
259const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
260const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
266const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
273const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
274const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
275const MAX_PREAUTH_CONNECTIONS: usize = 32;
276const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
277const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
278const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
279const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
280const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
281const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
287const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
295const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
303const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
310const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
315const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
316const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
317const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
318const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
319const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
320const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
321const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
322const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
323const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
324const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
325const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
326const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
327const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
330const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
340const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
341const GIT_STATUS_MAX_ENTRIES: usize = 128;
342const GIT_COMMIT_MAX_ENTRIES: usize = 12;
343const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
344const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
345const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
346const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
347const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
348const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
349const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
350const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
351const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
352const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
353 "provider-session-identity-allocation-failed";
354const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
355const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
356const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
357const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
358const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
359const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
360const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
361 "durable-state-path-semantics-unsupported";
362
363fn opaque_windows_path(value: String) -> OpaqueHostPath {
364 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
365}
366
367fn agent_progress_from_provider_snapshot(
368 address: SessionAddress,
369 provider: &ProviderSnapshot,
370) -> Option<SessionAgentProgress> {
371 let mut truncated = false;
372 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
373 let mut active_tool_labels = Vec::with_capacity(
374 provider
375 .active_tools
376 .len()
377 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
378 );
379 for tool in &provider.active_tools {
380 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
381 truncated = true;
382 break;
383 }
384 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
385 active_tool_labels.push(label);
386 }
387 }
388 let attention = provider
389 .interactions
390 .iter()
391 .find(|interaction| {
392 matches!(
393 interaction.status,
394 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
395 )
396 })
397 .map(|interaction| AgentProgressAttentionV1 {
398 kind: match interaction.interaction_kind {
399 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
400 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
401 },
402 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
403 });
404 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
405 let usage = (provider.completed_turns > 0
406 || provider.usage.input_tokens > 0
407 || provider.usage.output_tokens > 0
408 || provider.usage.cache_read_tokens > 0
409 || provider.usage.cache_write_tokens > 0
410 || provider.usage.reasoning_tokens > 0)
411 .then_some(AgentProgressUsageV1 {
412 input_tokens: provider.usage.input_tokens,
413 output_tokens: provider.usage.output_tokens,
414 cache_read_tokens: provider.usage.cache_read_tokens,
415 cache_write_tokens: provider.usage.cache_write_tokens,
416 reasoning_tokens: provider.usage.reasoning_tokens,
417 });
418 let progress = AgentProgressV1 {
419 provider_sequence: provider.sequence,
420 activity: provider.activity,
421 completed_turns: provider.completed_turns,
422 usage,
423 current: AgentProgressCurrentV1::from(provider.activity),
424 active_tool_labels,
425 active_tool_count,
426 attention,
427 subagent_count,
428 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
429 gap_count: provider.gap_count,
430 stale: provider.stale,
431 truncated,
432 };
433 let entry = SessionAgentProgress { address, progress };
434 serde_json::to_vec(&entry)
435 .ok()
436 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
437 .map(|_| entry)
438}
439
440fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
441 match u32::try_from(count) {
442 Ok(count) => count,
443 Err(_) => {
444 *truncated = true;
445 u32::MAX
446 }
447 }
448}
449
450fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
451 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
452 return None;
453 }
454 Some(match event {
455 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
456 ProviderEvent::SessionIdentityObserved { .. } => {
457 AgentProgressEventKindV1::SessionIdentityObserved
458 }
459 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
460 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
461 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
462 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
463 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
464 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
465 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
466 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
467 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
468 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
469 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
470 ProviderEvent::InteractionRequested { .. } => {
471 AgentProgressEventKindV1::InteractionRequested
472 }
473 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
474 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
475 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
476 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
477 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
478 ProviderEvent::UnrecognizedNotification { .. } => {
479 AgentProgressEventKindV1::UnrecognizedNotification
480 }
481 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
482 ProviderEvent::UserMessage { .. }
493 | ProviderEvent::Plan { .. }
494 | ProviderEvent::AvailableCommandsUpdated { .. }
495 | ProviderEvent::ModeChanged { .. }
496 | ProviderEvent::SessionInfoUpdated { .. }
497 | ProviderEvent::UsageUpdated { .. }
498 | ProviderEvent::ConfigOptionsUpdated { .. } => {
499 AgentProgressEventKindV1::UnrecognizedNotification
500 }
501 })
502}
503
504fn provider_family_is_live(family: AdapterFamily) -> bool {
517 match family {
518 AdapterFamily::PtySemantic
519 | AdapterFamily::Hook
520 | AdapterFamily::ManagedHook
521 | AdapterFamily::Pipe
522 | AdapterFamily::OneShot
523 | AdapterFamily::Acp => true,
524 AdapterFamily::History
525 | AdapterFamily::Resume
526 | AdapterFamily::SessionOptions
527 | AdapterFamily::CapabilityProbe => false,
528 }
529}
530
531fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
532 match by {
533 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
534 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
535 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
536 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
537 }
538}
539
540fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
552 match authority {
553 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
554 BlockAuthorityV1::HostPolicy => "denied by host policy",
555 BlockAuthorityV1::HostDeadline => {
556 "denied by host policy after the operator deadline expired"
557 }
558 BlockAuthorityV1::Operator => "declined by the operator",
559 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
560 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
561 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
562 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
563 BlockAuthorityV1::UserRejected => "rejected by the user",
564 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
565 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
566 }
567}
568
569fn host_request_denied_block(
572 method: &str,
573 by: ProviderHostDecisionAuthority,
574 reason: Option<&str>,
575) -> (String, BlockAuthorityV1, String) {
576 let authority = block_authority(by);
577 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
578 let (reason_text, _) =
582 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
583 (tool_class_label(method), authority, reason_text)
584}
585
586fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
605 match kind? {
606 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
607 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
608 _ => None,
609 }
610}
611
612fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
624 let trimmed = text.trim();
625 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
626 return (trimmed.to_owned(), None);
627 };
628 let tail = after_reason.trim_start();
629 let Some((reason, help)) = tail.split_once(". ") else {
630 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
634 return (reason.to_owned(), None);
635 };
636 let help = help.trim();
637 if help.is_empty() {
638 (reason.trim().to_owned(), None)
639 } else {
640 (reason.trim().to_owned(), Some(help.to_owned()))
641 }
642}
643
644fn tool_class_from_denial_help(help: Option<&str>) -> String {
654 help.and_then(|text| text.split("add a ").nth(1))
655 .and_then(|tail| tail.split(' ').next())
656 .filter(|word| !word.is_empty())
657 .map(str::to_owned)
658 .unwrap_or_else(|| "Tool".to_owned())
659}
660
661fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
669 fn names_quota(s: &str) -> bool {
670 let lower = s.to_ascii_lowercase();
671 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
672 }
673 vendor_code.is_some_and(names_quota) || names_quota(message)
674}
675
676fn provider_session_end_block(
692 stop_reason: &ProviderStopReason,
693) -> Option<(BlockAuthorityV1, String, String)> {
694 match stop_reason {
695 ProviderStopReason::Refusal => Some((
696 BlockAuthorityV1::ProviderRefusal,
697 "refusal".to_owned(),
698 "the provider ended the turn with stopReason: refusal".to_owned(),
699 )),
700 ProviderStopReason::ProviderError { message, vendor_code, .. }
701 if provider_error_is_quota(vendor_code.as_deref(), message) =>
702 {
703 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
704 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
705 }
706 _ => None,
707 }
708}
709
710fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
751 let ControlEventKind::ProviderEvent {
752 sequence: provider_sequence,
753 source,
754 source_sequence,
755 event: provider_event,
756 } = &event.event
757 else {
758 return None;
759 };
760 if !provider_family_is_live(source.family) {
761 return None;
762 }
763 let kind = match provider_event {
764 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
765 text: text.clone(),
766 is_delta: *is_delta,
767 },
768 ProviderEvent::Thinking { text } => {
769 AgentStreamChunkKindV1::Thinking { text: text.clone() }
770 }
771 ProviderEvent::InteractionRequested {
772 interaction_kind,
773 tool_name,
774 title,
775 prompt,
776 options,
777 ..
778 } => AgentStreamChunkKindV1::InteractionPrompt {
779 correlation_id: interaction_correlation(
780 event.instance_id,
781 event.generation,
782 *provider_sequence,
783 ),
784 interaction_kind: *interaction_kind,
785 tool_name: tool_name.clone(),
786 title: title.clone(),
795 prompt: prompt.clone(),
796 options: options
797 .iter()
798 .map(|option| AgentStreamInteractionOptionV1 {
799 option_id: option.option_id.clone(),
800 name: option.name.clone(),
801 kind: option.kind.clone(),
802 })
803 .collect(),
804 },
805 ProviderEvent::ConfigOptionsUpdated { options } => {
806 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
807 }
808 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
819 current: Some(mode_id.clone()),
820 available: available
821 .iter()
822 .map(|mode| AgentStreamNamedIdV1 {
823 id: mode.id.clone(),
824 name: mode.name.clone(),
825 description: mode.description.clone(),
826 })
827 .collect(),
828 },
829 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
833 let ProviderHostRequestDecision::Denied { by } = decision else {
834 return None;
835 };
836 let (tool_class, authority, reason_text) =
837 host_request_denied_block(method, *by, reason.as_deref());
838 AgentStreamChunkKindV1::Blocked {
839 correlation_id: None,
840 tool_class,
841 authority,
842 reason_kind: None,
843 reason: reason_text,
844 help: None,
845 }
846 }
847 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
851 let (authority, reason_kind) =
852 provider_tool_block_authority(non_execution_kind.as_deref())?;
853 let (reason, help) = split_provider_denial_text(output);
854 let (reason, _) =
855 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
856 let help = help.map(|help| {
857 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
858 });
859 AgentStreamChunkKindV1::Blocked {
860 correlation_id: Some(tool_correlation(event, source, id)),
861 tool_class: tool_class_from_denial_help(help.as_deref()),
862 authority,
863 reason_kind: Some(reason_kind.to_owned()),
864 reason,
865 help,
866 }
867 }
868 ProviderEvent::SessionEnded { stop_reason, .. } => {
872 let (authority, reason_kind, reason) =
873 stop_reason.as_ref().and_then(provider_session_end_block)?;
874 let (reason, _) =
875 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
876 AgentStreamChunkKindV1::Blocked {
877 correlation_id: None,
878 tool_class: "Session".to_owned(),
879 authority,
880 reason_kind: Some(reason_kind),
881 reason,
882 help: None,
883 }
884 }
885 ProviderEvent::SessionStarted { .. }
886 | ProviderEvent::SessionIdentityObserved { .. }
887 | ProviderEvent::TurnStarted { .. }
888 | ProviderEvent::WorkingObserved
889 | ProviderEvent::ToolStarted { .. }
890 | ProviderEvent::TurnCompleted { .. }
891 | ProviderEvent::ContextWindowUsage { .. }
892 | ProviderEvent::TurnInterrupted
893 | ProviderEvent::Error { .. }
894 | ProviderEvent::Ready
895 | ProviderEvent::InteractionResolved { .. }
896 | ProviderEvent::SubagentStarted { .. }
897 | ProviderEvent::SubagentStopped { .. }
898 | ProviderEvent::RateLimited { .. }
899 | ProviderEvent::UnrecognizedNotification { .. }
900 | ProviderEvent::UserMessage { .. }
901 | ProviderEvent::Plan { .. }
902 | ProviderEvent::AvailableCommandsUpdated { .. }
903 | ProviderEvent::SessionInfoUpdated { .. }
904 | ProviderEvent::UsageUpdated { .. } => return None,
905 };
906 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
907}
908
909async fn run_native_session_catalog_operation<T, E, F>(
910 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
911 operation: &'static str,
912 execute: F,
913) -> Result<T, NodeFailure>
914where
915 T: Send + 'static,
916 E: std::fmt::Display + Send + 'static,
917 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
918{
919 let task = tokio::task::spawn_blocking(move || {
920 let mut catalog = match catalog.try_lock() {
921 Ok(catalog) => catalog,
922 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
923 Err(std::sync::TryLockError::WouldBlock) => {
924 return Err(failure(
925 NodeFailureCode::BackendBusy,
926 "native session catalog operation is already running",
927 ));
928 }
929 };
930 execute(&mut catalog).map_err(|error| {
931 failure(
932 NodeFailureCode::BackendOperationFailed,
933 &format!("{operation} failed: {error}"),
934 )
935 })
936 });
937 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
938 .await
939 .map_err(|_| {
940 failure(
941 NodeFailureCode::BackendBusy,
942 &format!("{operation} exceeded its bounded deadline"),
943 )
944 })?
945 .map_err(|_| {
946 failure(
947 NodeFailureCode::BackendOperationFailed,
948 &format!("{operation} task failed"),
949 )
950 })?
951}
952
953fn windows_path_text(path: &OpaqueHostPath) -> &str {
954 path.as_utf8()
955 .expect("Windows node only creates UTF-8 opaque host paths")
956}
957
958fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
959 path.as_utf8()
960 .map(str::to_owned)
961 .ok_or_else(|| failure(
962 NodeFailureCode::InvalidRequest,
963 "this node requires UTF-8 host paths",
964 ))
965}
966
967fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
968 GitWorktreeSnapshot {
969 path: opaque_windows_path(worktree.path),
970 head: worktree.head,
971 branch: worktree.branch,
972 is_bare: worktree.is_bare,
973 is_main: worktree.is_main,
974 locked: worktree.locked,
975 lock_reason: worktree.lock_reason,
976 prunable: worktree.prunable,
977 prunable_reason: worktree.prunable_reason,
978 workspace_id: worktree.workspace_id.take(),
979 }
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct WorkspaceConfig {
984 workspace_id: WorkspaceId,
985 canonical_root: String,
986 worktree_service_mode: WorktreeServiceMode,
987 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
988}
989
990impl WorkspaceConfig {
991 pub fn new(
992 workspace_id: WorkspaceId,
993 root: impl AsRef<Path>,
994 ) -> Result<Self, NodeServerError> {
995 let root = root.as_ref();
996 if !root.is_absolute() {
997 return Err(NodeServerError::InvalidWorkspaceRoot {
998 workspace_id,
999 path: root.to_string_lossy().into_owned(),
1000 message: "path is not absolute".to_owned(),
1001 });
1002 }
1003 let canonical = std::fs::canonicalize(root).map_err(|error| {
1004 NodeServerError::InvalidWorkspaceRoot {
1005 workspace_id: workspace_id.clone(),
1006 path: root.to_string_lossy().into_owned(),
1007 message: error.to_string(),
1008 }
1009 })?;
1010 if !canonical.is_dir() {
1011 return Err(NodeServerError::InvalidWorkspaceRoot {
1012 workspace_id,
1013 path: root.to_string_lossy().into_owned(),
1014 message: "path is not a directory".to_owned(),
1015 });
1016 }
1017 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1018 NodeServerError::InvalidWorkspaceRoot {
1019 workspace_id: workspace_id.clone(),
1020 path: path.to_string_lossy().into_owned(),
1021 message: "canonical path is not valid Unicode".to_owned(),
1022 }
1023 })?;
1024 let canonical_root = platform::normalize_canonical_root(canonical_root);
1025 validate_workspace_root(&workspace_id, &canonical_root)?;
1026 Ok(Self {
1027 workspace_id,
1028 canonical_root,
1029 worktree_service_mode: WorktreeServiceMode::Manual,
1030 managed_worktree_profiles: BTreeMap::new(),
1031 })
1032 }
1033
1034 pub fn workspace_id(&self) -> &WorkspaceId {
1035 &self.workspace_id
1036 }
1037
1038 pub fn canonical_root(&self) -> &str {
1039 &self.canonical_root
1040 }
1041
1042 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1043 self.worktree_service_mode = mode;
1044 self
1045 }
1046
1047 pub fn with_managed_worktree_profile(
1048 mut self,
1049 profile: ManagedWorktreeProfile,
1050 ) -> Result<Self, NodeServerError> {
1051 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1052 NodeServerError::InvalidManagedWorktreeProfile {
1053 workspace_id: self.workspace_id.clone(),
1054 profile_id: profile.profile_id().clone(),
1055 message,
1056 }
1057 })?;
1058 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1059 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1060 workspace_id: self.workspace_id,
1061 profile_id: profile.profile_id().clone(),
1062 });
1063 }
1064 if self.managed_worktree_profiles.len()
1065 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1066 {
1067 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1068 workspace_id: self.workspace_id,
1069 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1070 });
1071 }
1072 self.managed_worktree_profiles
1073 .insert(profile.profile_id().clone(), profile);
1074 Ok(self)
1075 }
1076}
1077
1078#[derive(Clone)]
1079pub struct NodeServerConfig {
1080 pub endpoint: String,
1081 api_listen: Option<std::net::SocketAddr>,
1082 bridge_listen: Option<std::net::SocketAddr>,
1085 bridge_token: Option<String>,
1088 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1091 call_home: Option<std::net::SocketAddr>,
1094 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1098 pub node_id: NodeId,
1099 pub workspaces: Vec<WorkspaceConfig>,
1100 access_token: String,
1101 pub runtime: NativeRuntimeConfig,
1102 session_record_retention: SessionRecordRetentionConfig,
1103 state_path: Option<PathBuf>,
1104 spawn_profiles: SpawnProfileRegistry,
1105 session_environment: Option<NodeSessionEnvironmentConfig>,
1106 history: Option<NativeHistoryConfig>,
1107 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1108 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1112 #[cfg(feature = "fixture")]
1113 fixture_raw_pty_runtime: bool,
1114}
1115
1116#[derive(Clone)]
1117struct NodeSessionEnvironmentConfig {
1118 root: PathBuf,
1119 resolver: Arc<dyn NodeSecretResolver>,
1120}
1121
1122impl NodeServerConfig {
1123 pub fn new(
1124 endpoint: impl Into<String>,
1125 access_token: impl Into<String>,
1126 node_id: NodeId,
1127 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1128 ) -> Result<Self, NodeServerError> {
1129 let endpoint = endpoint.into();
1130 let access_token = access_token.into();
1131 if !platform::validate_endpoint(&endpoint) {
1132 return Err(NodeServerError::InvalidEndpoint);
1133 }
1134 if access_token.is_empty() || access_token.len() > 4_096 {
1135 return Err(NodeServerError::InvalidAccessToken);
1136 }
1137 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1138 if workspaces.is_empty() {
1139 return Err(NodeServerError::NoWorkspaces);
1140 }
1141 let mut workspace_ids = BTreeMap::new();
1142 let mut workspace_roots = BTreeMap::new();
1143 for workspace in &workspaces {
1144 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1145 && workspace.managed_worktree_profiles.is_empty()
1146 {
1147 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1148 workspace.workspace_id.clone(),
1149 ));
1150 }
1151 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1152 && !workspace.managed_worktree_profiles.is_empty()
1153 {
1154 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1155 workspace.workspace_id.clone(),
1156 ));
1157 }
1158 if workspace_ids
1159 .insert(workspace.workspace_id.clone(), ())
1160 .is_some()
1161 {
1162 return Err(NodeServerError::DuplicateWorkspaceId(
1163 workspace.workspace_id.clone(),
1164 ));
1165 }
1166 let root_key = platform::root_identity(&workspace.canonical_root);
1167 if let Some(existing) = workspace_roots.insert(
1168 root_key,
1169 workspace.workspace_id.clone(),
1170 ) {
1171 return Err(NodeServerError::DuplicateWorkspaceRoot {
1172 first: existing,
1173 second: workspace.workspace_id.clone(),
1174 root: workspace.canonical_root.clone(),
1175 });
1176 }
1177 }
1178 Ok(Self {
1179 endpoint,
1180 api_listen: None,
1181 bridge_listen: None,
1182 bridge_token: None,
1183 bridge_underlay: None,
1184 call_home: None,
1185 call_home_identity: None,
1186 node_id,
1187 workspaces,
1188 access_token,
1189 runtime: NativeRuntimeConfig {
1192 worker_poll_interval_ms: 4,
1193 ..NativeRuntimeConfig::default()
1194 },
1195 session_record_retention: SessionRecordRetentionConfig::default(),
1196 state_path: None,
1197 spawn_profiles: SpawnProfileRegistry::default(),
1198 session_environment: None,
1199 history: None,
1200 harness_mcp_helper: None,
1201 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1202 #[cfg(feature = "fixture")]
1203 fixture_raw_pty_runtime: false,
1204 })
1205 }
1206
1207 pub fn with_api_listen(
1208 mut self,
1209 api_listen: std::net::SocketAddr,
1210 ) -> Result<Self, NodeServerError> {
1211 if !api_listen.ip().is_loopback() {
1212 return Err(NodeServerError::InvalidApiListen(api_listen));
1213 }
1214 self.api_listen = Some(api_listen);
1215 Ok(self)
1216 }
1217
1218 pub fn with_bridge_listen(
1222 mut self,
1223 bridge_listen: std::net::SocketAddr,
1224 ) -> Result<Self, NodeServerError> {
1225 if !bridge_listen.ip().is_loopback() {
1226 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1227 }
1228 self.bridge_listen = Some(bridge_listen);
1229 Ok(self)
1230 }
1231
1232 pub fn with_bridge_token(
1235 mut self,
1236 bridge_token: impl Into<String>,
1237 ) -> Result<Self, NodeServerError> {
1238 let bridge_token = bridge_token.into();
1239 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1240 return Err(NodeServerError::InvalidBridgeToken);
1241 }
1242 self.bridge_token = Some(bridge_token);
1243 Ok(self)
1244 }
1245
1246 pub fn with_bridge_underlay(
1251 mut self,
1252 bind: std::net::SocketAddr,
1253 transport_key: [u8; 32],
1254 underlay_token: impl Into<String>,
1255 ) -> Result<Self, NodeServerError> {
1256 if self.bridge_listen.is_none() {
1257 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1258 }
1259 if self.bridge_token.is_none() {
1260 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1261 }
1262 let underlay_token = underlay_token.into();
1263 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1264 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1265 }
1266 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1268 bind,
1269 transport_key,
1270 underlay_token,
1271 });
1272 Ok(self)
1273 }
1274
1275 pub fn with_call_home(
1296 mut self,
1297 relay: std::net::SocketAddr,
1298 ) -> Result<Self, NodeServerError> {
1299 if relay.port() == 0 || relay.ip().is_unspecified() {
1302 return Err(NodeServerError::InvalidCallHome(relay));
1303 }
1304 self.call_home = Some(relay);
1305 Ok(self)
1306 }
1307
1308 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1310 self.call_home_identity = Some(identity);
1311 self
1312 }
1313
1314 pub fn with_state_path(
1315 mut self,
1316 state_path: impl Into<PathBuf>,
1317 ) -> Result<Self, NodeServerError> {
1318 let state_path = state_path.into();
1319 if !state_path.is_absolute() || state_path.file_name().is_none() {
1320 return Err(NodeServerError::InvalidStatePath(
1321 state_path.to_string_lossy().into_owned(),
1322 ));
1323 }
1324 self.state_path = Some(state_path);
1325 Ok(self)
1326 }
1327
1328 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1329 self.spawn_profiles = spawn_profiles;
1330 self
1331 }
1332
1333 pub fn with_session_record_retention(
1339 mut self,
1340 retention: SessionRecordRetentionConfig,
1341 ) -> Self {
1342 self.session_record_retention = retention;
1343 self
1344 }
1345
1346 pub fn with_session_environment_materialization(
1347 mut self,
1348 root: impl Into<PathBuf>,
1349 resolver: Arc<dyn NodeSecretResolver>,
1350 ) -> Result<Self, NodeServerError> {
1351 let root = root.into();
1352 if !root.is_absolute() {
1353 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1354 }
1355 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1356 Ok(self)
1357 }
1358
1359 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1362 self.history = Some(history);
1363 self
1364 }
1365
1366 pub fn with_harness_mcp_helper(
1372 mut self,
1373 helper_program: impl Into<PathBuf>,
1374 ) -> Result<Self, NodeServerError> {
1375 self.harness_mcp_helper = Some(
1376 ReviewedHarnessMcpProgram::review(helper_program.into())
1377 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1378 );
1379 Ok(self)
1380 }
1381
1382 pub fn with_network_allowlist_catalog(
1387 mut self,
1388 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1389 ) -> Result<Self, NodeServerError> {
1390 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1391 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1392 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1393 });
1394 }
1395 self.network_allowlist_catalog = catalog;
1396 Ok(self)
1397 }
1398
1399 pub fn with_network_allowlist_catalog_file(
1403 self,
1404 path: impl AsRef<std::path::Path>,
1405 ) -> Result<Self, NodeServerError> {
1406 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1407 .map_err(NodeServerError::from)?;
1408 self.with_network_allowlist_catalog(catalog)
1409 }
1410}
1411
1412pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1413 platform::default_state_path(node_id.as_str())
1414 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1415}
1416
1417pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1418 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1419}
1420
1421fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1422 let parent = state_path.parent()?;
1423 let mut name = state_path.file_name()?.to_os_string();
1424 name.push(".delivery-store");
1425 Some(parent.join(name))
1426}
1427
1428fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1429 let parent = state_path.parent()?;
1430 let mut name = state_path.file_name()?.to_os_string();
1431 name.push(".context-pack-store");
1432 Some(parent.join(name))
1433}
1434
1435pub struct NodeServer {
1436 config: NodeServerConfig,
1437 runtime: NativeRuntime,
1438 shared: Arc<NodeShared>,
1439 events: EventSubscription,
1440 state_path_lock: Option<session_registry::StatePathLock>,
1441}
1442
1443#[cfg(feature = "fixture")]
1444#[derive(Clone, Default)]
1445pub struct SpawnManagedWorktreeV2FailureProbe {
1446 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1447}
1448
1449#[cfg(feature = "fixture")]
1450impl SpawnManagedWorktreeV2FailureProbe {
1451 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1452 *self
1453 .latest_code
1454 .lock()
1455 .unwrap_or_else(|poisoned| poisoned.into_inner())
1456 }
1457
1458 fn record(&self, code: Option<NodeFailureCode>) {
1459 *self
1460 .latest_code
1461 .lock()
1462 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1463 }
1464}
1465
1466#[cfg(all(feature = "fixture", windows))]
1467struct IsolatedCodexHomeFixtureEnvironment;
1468
1469#[cfg(all(feature = "fixture", windows))]
1470impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1471 for IsolatedCodexHomeFixtureEnvironment
1472{
1473 fn resolve_child_environment(
1474 &self,
1475 ) -> Result<
1476 Vec<gate4agent_catalog::EnvMutation>,
1477 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1478 > {
1479 Ok(vec![gate4agent_catalog::EnvMutation {
1480 key: OsString::from("USERPROFILE"),
1481 value: None,
1482 }])
1483 }
1484}
1485
1486impl NodeServer {
1487 #[cfg(unix)]
1491 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1492 self.shared.signer_proxy.set(proxy).is_ok()
1493 }
1494
1495 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1496 self.shared.m4a_accounts.as_ref()?;
1497 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1498 }
1499
1500 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1501 let catalog = active_registry()?;
1502 Self::new_with_registry(config, catalog)
1503 }
1504
1505 #[cfg(feature = "fixture")]
1506 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1507 let mut spec = gate4agent_testkit::interactive_agent_spec();
1508 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1510 Self::new_fixture_with_spec(config, spec)
1511 }
1512
1513 #[cfg(feature = "fixture")]
1514 pub fn new_clean_exit_fixture(
1515 config: NodeServerConfig,
1516 fixture_root: PathBuf,
1517 started_marker: PathBuf,
1518 release_signal: PathBuf,
1519 ) -> Result<Self, NodeServerError> {
1520 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1521 &fixture_root,
1522 &started_marker,
1523 &release_signal,
1524 )
1525 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1526 spec.id = AgentId::new("claude")
1527 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1528 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1529 Self::new_fixture_with_spec(config, spec)
1530 }
1531
1532 #[cfg(all(feature = "fixture", windows))]
1542 pub fn new_durable_context_pack_clean_exit_fixture(
1543 config: NodeServerConfig,
1544 fixture_root: PathBuf,
1545 started_marker: PathBuf,
1546 release_signal: PathBuf,
1547 session_id: &str,
1548 ) -> Result<Self, NodeServerError> {
1549 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1550 &fixture_root,
1551 &started_marker,
1552 &release_signal,
1553 session_id,
1554 )
1555 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1556 let source_id = AgentId::new("claude")
1557 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1558 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1559 NodeServerError::Registry(
1560 "durable context pack clean-exit fixture source adapter is unavailable"
1561 .to_owned(),
1562 )
1563 })?;
1564 source_spec.capabilities.adapters.history =
1565 source_provider.capabilities.adapters.history.clone();
1566 source_spec.id = source_id;
1567 source_spec.display_name =
1568 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1569 source_spec.detection.command =
1575 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1576 source_spec.detection.aliases.clear();
1577
1578 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1579 target_spec.id = AgentId::new("codex")
1580 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1581 target_spec.display_name =
1582 "Controlled Codex durable context-pack target fixture".to_owned();
1583 target_spec.detection.command =
1584 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1585 target_spec.detection.aliases.clear();
1586
1587 let catalog = AgentRegistry::new([source_spec, target_spec])
1588 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1589 let mut server = Self::new_with_registry(config, catalog)?;
1590 Arc::get_mut(&mut server.shared)
1591 .expect("fixture Node shared state must remain uniquely owned before run")
1592 .fixture_semantic_hook_policy = true;
1593 Ok(server)
1594 }
1595
1596 #[cfg(feature = "fixture")]
1597 pub fn new_monitoring_hook_fixture(
1598 config: NodeServerConfig,
1599 ) -> Result<Self, NodeServerError> {
1600 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1601 spec.id = AgentId::new("claude")
1602 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1603 let mut server = Self::new_fixture_with_spec(config, spec)?;
1604 Arc::get_mut(&mut server.shared)
1605 .expect("fixture Node shared state must remain uniquely owned before run")
1606 .fixture_semantic_hook_policy = true;
1607 Ok(server)
1608 }
1609
1610 #[cfg(all(feature = "fixture", windows))]
1611 pub fn new_provider_bundle_argv_fixture(
1612 config: NodeServerConfig,
1613 agent_id: AgentId,
1614 proof_path: PathBuf,
1615 ) -> Result<Self, NodeServerError> {
1616 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1617 Self::new_fixture_with_spec(config, spec)
1618 }
1619
1620 #[cfg(all(feature = "fixture", windows))]
1621 pub fn new_provider_bundle_argv_hold_fixture(
1622 config: NodeServerConfig,
1623 agent_id: AgentId,
1624 proof_path: PathBuf,
1625 release_signal: PathBuf,
1626 ) -> Result<Self, NodeServerError> {
1627 let spec = Self::provider_bundle_argv_fixture_spec(
1628 agent_id,
1629 proof_path,
1630 Some(release_signal),
1631 )?;
1632 Self::new_fixture_with_spec(config, spec)
1633 }
1634
1635 #[cfg(all(feature = "fixture", windows))]
1636 fn provider_bundle_argv_fixture_spec(
1637 agent_id: AgentId,
1638 proof_path: PathBuf,
1639 release_signal: Option<PathBuf>,
1640 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1641 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1642 return Err(NodeServerError::Registry(
1643 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1644 ));
1645 }
1646 let mut spec = gate4agent_testkit::interactive_agent_spec();
1647 spec.id = agent_id.clone();
1648 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1649 let provider = builtin_registry()
1650 .get(&agent_id)
1651 .ok_or_else(|| {
1652 NodeServerError::Registry(
1653 "provider bundle argv fixture adapter is unavailable".to_owned(),
1654 )
1655 })?;
1656 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1657 if !proof_path.is_absolute()
1658 || !proof_path
1659 .parent()
1660 .is_some_and(|parent| parent.is_dir())
1661 {
1662 return Err(NodeServerError::Registry(
1663 "provider bundle argv proof path requires an existing absolute parent"
1664 .to_owned(),
1665 ));
1666 }
1667 if let Some(release_signal) = release_signal.as_ref() {
1668 if !release_signal.is_absolute()
1669 || !release_signal
1670 .parent()
1671 .is_some_and(|parent| parent.is_dir())
1672 {
1673 return Err(NodeServerError::Registry(
1674 "provider bundle argv release path requires an existing absolute parent"
1675 .to_owned(),
1676 ));
1677 }
1678 if release_signal.exists() {
1679 return Err(NodeServerError::Registry(
1680 "provider bundle argv release path must not exist".to_owned(),
1681 ));
1682 }
1683 let proof_parent = std::fs::canonicalize(
1684 proof_path
1685 .parent()
1686 .expect("validated provider bundle proof parent"),
1687 )
1688 .map_err(|_| {
1689 NodeServerError::Registry(
1690 "provider bundle argv proof parent is unavailable".to_owned(),
1691 )
1692 })?;
1693 let release_parent = std::fs::canonicalize(
1694 release_signal
1695 .parent()
1696 .expect("validated provider bundle release parent"),
1697 )
1698 .map_err(|_| {
1699 NodeServerError::Registry(
1700 "provider bundle argv release parent is unavailable".to_owned(),
1701 )
1702 })?;
1703 if proof_parent != release_parent {
1704 return Err(NodeServerError::Registry(
1705 "provider bundle argv release path must share the proof parent".to_owned(),
1706 ));
1707 }
1708 }
1709 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1710 NodeServerError::Registry(
1711 "provider bundle argv proof path is not valid Unicode".to_owned(),
1712 )
1713 })?;
1714 let release_signal = release_signal.map(|release_signal| {
1715 release_signal.into_os_string().into_string().map_err(|_| {
1716 NodeServerError::Registry(
1717 "provider bundle argv release path is not valid Unicode".to_owned(),
1718 )
1719 })
1720 }).transpose()?;
1721 let script = spec
1722 .launch
1723 .fixed_args
1724 .pop()
1725 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1726 let bundle_validation = if agent_id.as_str() == "codex" {
1727 "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1728 } else if agent_id.as_str() == "claude" {
1729 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1730 } else {
1731 "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1732 };
1733 if let Some(release_signal) = release_signal {
1734 spec.launch.fixed_args.push(format!(
1735 "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1736 ));
1737 spec.launch.fixed_args.push(proof_path);
1738 spec.launch.fixed_args.push(release_signal);
1739 } else {
1740 spec.launch.fixed_args.push(format!(
1741 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1742 ));
1743 spec.launch.fixed_args.push(proof_path);
1744 }
1745 Ok(spec)
1746 }
1747
1748 #[cfg(all(feature = "fixture", windows))]
1749 pub fn new_context_pack_fixture(
1750 config: NodeServerConfig,
1751 proof_path: PathBuf,
1752 ) -> Result<Self, NodeServerError> {
1753 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1754 Self::new_with_registry(config, catalog)
1755 }
1756
1757 #[cfg(all(feature = "fixture", windows))]
1758 pub fn new_monitoring_context_pack_fixture(
1759 config: NodeServerConfig,
1760 proof_path: PathBuf,
1761 ) -> Result<Self, NodeServerError> {
1762 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1763 let mut server = Self::new_with_registry(config, catalog)?;
1764 Arc::get_mut(&mut server.shared)
1765 .expect("fixture Node shared state must remain uniquely owned before run")
1766 .fixture_semantic_hook_policy = true;
1767 Ok(server)
1768 }
1769
1770 #[cfg(all(feature = "fixture", windows))]
1771 pub fn install_isolated_codex_home_environment_fixture(
1772 &mut self,
1773 profile_id: SpawnEnvironmentProfileId,
1774 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1775 ) -> Result<(), NodeServerError> {
1776 let provider = AgentId::new("codex")
1777 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1778 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1779 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1780 "{}-pty",
1781 profile_id.as_str(),
1782 ))
1783 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1784 provider.clone(),
1785 TransportKind::Pty,
1786 vec![OsString::from("USERPROFILE")],
1787 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1788 )
1789 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1790 let materialization = NodeSessionMaterializationProfile::new(
1791 Vec::new(),
1792 vec![crate::session_environment::NodeSessionPathBinding::new(
1793 "CODEX_HOME",
1794 crate::session_environment::NodeSessionPathClass::ProviderHome,
1795 )
1796 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1797 Vec::new(),
1798 )
1799 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1800 self.install_environment_profile(
1801 NodeEnvironmentProfile::new_with_materialization(
1802 profile_id,
1803 profile_revision,
1804 provider,
1805 [native_profile],
1806 Some(materialization),
1807 )
1808 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1809 )
1810 }
1811
1812 #[cfg(all(feature = "fixture", windows))]
1813 pub fn new_context_only_proof_fixture(
1814 config: NodeServerConfig,
1815 target_provider: AgentId,
1816 proof_path: PathBuf,
1817 ) -> Result<Self, NodeServerError> {
1818 if target_provider.as_str() != "kimi" {
1819 return Err(NodeServerError::Registry(
1820 "context-only proof fixture requires Kimi".to_owned(),
1821 ));
1822 }
1823 let catalog = Self::context_pack_fixture_catalog(
1824 proof_path,
1825 Some(&target_provider),
1826 false,
1827 )?;
1828 Self::new_with_registry(config, catalog)
1829 }
1830
1831 #[cfg(all(feature = "fixture", windows))]
1832 fn context_pack_fixture_catalog(
1833 proof_path: PathBuf,
1834 context_only_target: Option<&AgentId>,
1835 monitoring_claude_source: bool,
1836 ) -> Result<AgentRegistry, NodeServerError> {
1837 if !proof_path.is_absolute()
1838 || !proof_path
1839 .parent()
1840 .is_some_and(|parent| parent.is_dir())
1841 {
1842 return Err(NodeServerError::Registry(
1843 "context pack proof path requires an existing absolute parent".to_owned(),
1844 ));
1845 }
1846 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1847 NodeServerError::Registry(
1848 "context pack proof path is not valid Unicode".to_owned(),
1849 )
1850 })?;
1851 let providers = ["claude", "codex", "grok", "kimi"];
1852 let builtins = builtin_registry();
1853 let mut specs = Vec::with_capacity(providers.len());
1854 for provider_id in providers {
1855 let agent_id = AgentId::new(provider_id)
1856 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1857 let provider = builtins.get(&agent_id).ok_or_else(|| {
1858 NodeServerError::Registry(
1859 "context pack fixture provider adapter is unavailable".to_owned(),
1860 )
1861 })?;
1862 let monitored_provider =
1863 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1864 let mut spec = if monitored_provider {
1865 gate4agent_testkit::monitoring_hook_agent_spec()
1866 } else {
1867 gate4agent_testkit::interactive_agent_spec()
1868 };
1869 spec.id = agent_id.clone();
1870 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1871 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1872 spec.detection.aliases.clear();
1873 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1874 if provider_id == "codex" {
1875 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1876 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1877 })?;
1878 let validation = r#"
1879$bundleArgs = @($bundleArgs)
1880if ($bundleArgs.Count -ne 0) { exit 91 }
1881$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1882$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1883$cwd = (Get-Location).ProviderPath
1884if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1885$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1886if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1887$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1888if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1892if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1893$contextPath = Join-Path $contextRoot 'context-pack.json'
1894if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1895$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1896if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1897$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1898$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1899$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1900$separator = [IO.Path]::DirectorySeparatorChar
1901if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1902if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1903$rawContext = [IO.File]::ReadAllText($contextPath)
1904if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1905try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1906$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1907$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1908foreach ($property in @($document.PSObject.Properties.Name)) {
1909 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1910}
1911foreach ($property in $requiredTopLevel) {
1912 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1913}
1914if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1915if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1916$messages = @($document.retained_messages)
1917if ($messages.Count -eq 0) { exit 107 }
1918foreach ($message in $messages) {
1919 $messageProperties = @($message.PSObject.Properties.Name)
1920 foreach ($property in $messageProperties) {
1921 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1922 }
1923 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1924}
1925$sha = [Security.Cryptography.SHA256]::Create()
1926try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1927[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1928$rawProof = [IO.File]::ReadAllText($proofPath)
1929if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1930[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1931"#;
1932 spec.launch.fixed_args.push(format!(
1933 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1934 ));
1935 spec.launch.fixed_args.push(proof_path.clone());
1936 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1937 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1938 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1939 })?;
1940 let validation = r#"
1941$bundleArgs = @($bundleArgs)
1942if ($bundleArgs.Count -ne 0) { exit 91 }
1943$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1944$cwd = (Get-Location).ProviderPath
1945if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1946$contextPath = Join-Path $contextRoot 'context-pack.json'
1947if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1948$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1949if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1950$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1951$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1952$separator = [IO.Path]::DirectorySeparatorChar
1953if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1954try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1955if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1956if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1957$messages = @($document.retained_messages)
1958if ($messages.Count -eq 0) { exit 99 }
1959$hasUser = $false
1960$hasAssistant = $false
1961foreach ($message in $messages) {
1962 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1963 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1964 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1965 else { exit 101 }
1966}
1967if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1968$sha = [Security.Cryptography.SHA256]::Create()
1969try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1970[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1971[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1972"#;
1973 spec.launch.fixed_args.push(format!(
1974 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1975 ));
1976 spec.launch.fixed_args.push(proof_path.clone());
1977 }
1978 specs.push(spec);
1979 }
1980 let catalog = AgentRegistry::new(specs)
1981 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1982 Ok(catalog)
1983 }
1984
1985 #[cfg(feature = "fixture")]
1986 pub fn new_exact_launcher_fixture(
1987 config: NodeServerConfig,
1988 agent_id: AgentId,
1989 launcher: String,
1990 ) -> Result<Self, NodeServerError> {
1991 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1992 return Err(NodeServerError::Registry(
1993 "exact launcher fixture requires a supported provider".to_owned(),
1994 ));
1995 }
1996 if launcher.contains('\0') {
1997 return Err(NodeServerError::Registry(
1998 "exact launcher fixture path contains NUL".to_owned(),
1999 ));
2000 }
2001 let launcher_path = Path::new(&launcher);
2002 if !launcher_path.is_absolute() {
2003 return Err(NodeServerError::Registry(
2004 "exact launcher fixture path must be absolute".to_owned(),
2005 ));
2006 }
2007 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2008 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2009 })?;
2010 if !metadata.is_file() {
2011 return Err(NodeServerError::Registry(
2012 "exact launcher fixture path must be a regular file".to_owned(),
2013 ));
2014 }
2015 let mut spec = builtin_registry()
2016 .get(&agent_id)
2017 .ok_or_else(|| {
2018 NodeServerError::Registry(
2019 "exact launcher fixture provider is unavailable".to_owned(),
2020 )
2021 })?
2022 .clone();
2023 spec.launch.program = launcher;
2024 Self::new_fixture_with_spec(config, spec)
2025 }
2026
2027 #[cfg(feature = "fixture")]
2036 fn h3b_provider_spec_with_program_and_args(
2037 agent_id: AgentId,
2038 provider_program: PathBuf,
2039 fixed_args: Vec<OsString>,
2040 ) -> Result<AgentSpec, NodeServerError> {
2041 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2042 return Err(NodeServerError::Registry(
2043 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2044 ));
2045 }
2046 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2047 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2048 })?;
2049 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2050 return Err(NodeServerError::Registry(
2051 "H3B fixture provider must be an exact regular file".to_owned(),
2052 ));
2053 }
2054 let program = provider_program.into_os_string().into_string().map_err(|_| {
2055 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2056 })?;
2057 let fixed_args = fixed_args.into_iter().map(|argument| {
2058 argument.into_string().map_err(|_| NodeServerError::Registry(
2059 "H3B fixture argument is not Unicode".to_owned(),
2060 ))
2061 }).collect::<Result<Vec<_>, _>>()?;
2062 let argument_bytes = fixed_args.iter()
2063 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2064 if program.contains('\0')
2065 || fixed_args.iter().any(|argument| argument.contains('\0'))
2066 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2067 {
2068 return Err(NodeServerError::Registry(
2069 "H3B fixture provider or arguments are invalid".to_owned(),
2070 ));
2071 }
2072 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2073 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2074 })?.clone();
2075 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2076 .ok_or_else(|| NodeServerError::Registry(
2077 "H3B fixture provider basename is unavailable".to_owned(),
2078 ))?.to_owned();
2079 spec.detection.command = process_name.clone();
2080 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2081 name: process_name,
2082 }];
2083 spec.launch.program = program;
2084 spec.launch.fixed_args = fixed_args;
2085 Ok(spec)
2086 }
2087
2088 #[cfg(feature = "fixture")]
2089 pub fn new_harness_mcp_proxy_fixture(
2090 config: NodeServerConfig,
2091 provider_program: PathBuf,
2092 fixed_args: Vec<OsString>,
2093 harness_mcp_program: PathBuf,
2094 ) -> Result<Self, NodeServerError> {
2095 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2096 let agent_id = AgentId::new("codex")
2097 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2098 let mut spec =
2099 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2100 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2101 config.fixture_raw_pty_runtime = true;
2102 Self::new_fixture_with_spec(config, spec)
2103 }
2104
2105 #[cfg(feature = "fixture")]
2129 pub fn new_harness_mcp_acp_launcher_fixture(
2130 config: NodeServerConfig,
2131 helper_program: PathBuf,
2132 provider: AgentId,
2133 program: PathBuf,
2134 args: Vec<OsString>,
2135 ) -> Result<Self, NodeServerError> {
2136 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2137 return Err(NodeServerError::Registry(
2138 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2139 ));
2140 }
2141 let config = config.with_harness_mcp_helper(helper_program)?;
2142 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2143 let launch = spec.launch.clone();
2144 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2145 NodeServerError::Registry(
2146 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2147 .to_owned(),
2148 )
2149 })?;
2150 acp.launch_override = Some(launch);
2158 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2159 Self::new_fixture_with_spec(config, spec)
2160 }
2161
2162 #[cfg(feature = "fixture")]
2163 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2164 let mut spec = gate4agent_testkit::interactive_agent_spec();
2165 let claude_id = AgentId::new("claude")
2166 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2167 let claude = builtin_registry()
2168 .get(&claude_id)
2169 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2170 spec.id = claude_id;
2171 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2172 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2173 name: "claude".to_owned(),
2174 }];
2175 spec.capabilities.transports.pty_adapter = claude
2176 .capabilities
2177 .transports
2178 .pty_adapter
2179 .clone();
2180 spec.capabilities.adapters.resume = claude
2181 .capabilities
2182 .adapters
2183 .resume
2184 .clone();
2185 let script = spec
2186 .launch
2187 .fixed_args
2188 .pop()
2189 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2190 spec.launch.fixed_args.push(format!(
2191 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2192 ));
2193 Self::new_fixture_with_spec(config, spec)
2194 }
2195
2196 #[cfg(feature = "fixture")]
2197 pub fn new_cmd_cwd_fixture(
2198 config: NodeServerConfig,
2199 command: String,
2200 ) -> Result<Self, NodeServerError> {
2201 let mut spec = gate4agent_testkit::interactive_agent_spec();
2202 spec.id = AgentId::new("claude")
2203 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2204 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2205 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2206 spec.launch.program = command;
2207 spec.launch.fixed_args.clear();
2208 Self::new_fixture_with_spec(config, spec)
2209 }
2210
2211 #[cfg(feature = "fixture")]
2212 fn new_fixture_with_spec(
2213 config: NodeServerConfig,
2214 spec: gate4agent_types::AgentSpec,
2215 ) -> Result<Self, NodeServerError> {
2216 let catalog = AgentRegistry::new([spec])
2217 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2218 Self::new_with_registry(config, catalog)
2219 }
2220
2221 fn new_with_registry(
2222 config: NodeServerConfig,
2223 catalog: AgentRegistry,
2224 ) -> Result<Self, NodeServerError> {
2225 for profile in config.spawn_profiles.iter() {
2226 if catalog.get(&profile.provider).is_none() {
2227 return Err(NodeServerError::Registry(
2228 "spawn profile references an unavailable provider".to_owned(),
2229 ));
2230 }
2231 }
2232 let input_settle_timeout_ms = catalog
2233 .iter()
2234 .map(|spec| spec.readiness.timeout_ms)
2235 .max()
2236 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2237 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2238 .max(MUTATION_SETTLE_TIMEOUT_MS);
2239 let (provider_contracts, provider_adapter_contracts) =
2240 provider_contract_manifest(&catalog)?;
2241 let compatibility_support = node_compatibility_support_for_manifest(
2242 &provider_contracts,
2243 &provider_adapter_contracts,
2244 )?;
2245 validate_node_negotiated_handshake_capacity(&compatibility_support)
2246 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2247 let enabled_providers = provider_contracts
2248 .iter()
2249 .map(|contract| contract.provider.clone())
2250 .collect();
2251 #[cfg(feature = "fixture")]
2252 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2253 #[cfg(not(feature = "fixture"))]
2254 let fixture_raw_pty_runtime = false;
2255 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2256 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2257 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2258 })).expect("fixture provider catalog remains bounded");
2259 (None, statuses)
2260 } else {
2261 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2262 let statuses = monitor.collect();
2263 (Some(monitor), statuses)
2264 };
2265 let state_path_lock =
2266 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2267 .map_err(|error| {
2268 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2269 })?;
2270 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2271 Some(state_path) => {
2272 let delivery_root = delivery_store_root_for_state_path(state_path)
2273 .ok_or_else(|| {
2274 NodeServerError::DeliveryStore(
2275 "delivery store root could not be derived from state path".to_owned(),
2276 )
2277 })?;
2278 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2279 let detail = match &error {
2280 DeliveryStoreError::Storage(io_error) => {
2281 format!("{error}: {io_error}")
2282 }
2283 _ => error.to_string(),
2284 };
2285 NodeServerError::DeliveryStore(detail)
2286 })?;
2287 (Some(store), bundles)
2288 }
2289 None => (None, Vec::new()),
2290 };
2291 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2292 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2293 })?;
2294 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2295 Some(state_path) => {
2296 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2297 .ok_or(NodeServerError::ContextPackStore)?;
2298 let (store, packs) = ContextPackStore::open(context_pack_root)
2299 .map_err(|_| NodeServerError::ContextPackStore)?;
2300 (Some(store), packs)
2301 }
2302 None => (None, Vec::new()),
2303 };
2304 let mut durable_context_catalog = ContextPackCatalog::default();
2305 for pack in durable_context_packs {
2306 durable_context_catalog
2307 .insert(pack)
2308 .map_err(|_| NodeServerError::ContextPackStore)?;
2309 }
2310 let session_environment_materializer = config
2311 .session_environment
2312 .as_ref()
2313 .map(|environment| {
2314 SessionEnvironmentMaterializer::new(
2315 environment.root.clone(),
2316 Arc::clone(&environment.resolver),
2317 )
2318 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2319 })
2320 .transpose()?;
2321 #[cfg(unix)]
2324 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2325 let native_session_catalog = config.history.clone().map(|history| {
2326 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2327 });
2328 let (handle, runtime) = match config.history.clone() {
2329 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2330 None => NativeRuntime::new(catalog, config.runtime),
2331 };
2332 let native_launch_profile_control = runtime.native_launch_profile_control();
2333 let harness_mcp_registry = config
2334 .harness_mcp_helper
2335 .clone()
2336 .map(HarnessMcpProxyRegistry::new);
2337 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2338 let incarnation_id = random_incarnation_id()
2339 .map_err(NodeServerError::IncarnationIdentity)?;
2340 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2341 .map_err(durable_state_load_error)?;
2342 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2343 let managed_worktree_tombstones =
2344 std::mem::take(&mut loaded.managed_worktree_tombstones);
2345 let materializations = std::mem::take(&mut loaded.materializations);
2346 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2347 let (workspaces, records, persistence_warning) =
2348 merge_durable_state(&config.workspaces, loaded)?;
2349 let mut shared = NodeShared::new_with_incarnation(
2350 handle,
2351 config.access_token.clone(),
2352 config.node_id.clone(),
2353 incarnation_id,
2354 workspaces,
2355 enabled_providers,
2356 provider_runtime_statuses,
2357 provider_runtime_monitor,
2358 provider_contracts,
2359 provider_adapter_contracts,
2360 config.spawn_profiles.clone(),
2361 Some(native_launch_profile_control),
2362 native_session_catalog,
2363 session_environment_materializer,
2364 config.state_path.clone(),
2365 records,
2366 managed_worktrees,
2367 managed_worktree_tombstones,
2368 materializations,
2369 persistence_warning,
2370 input_settle_timeout_ms,
2371 );
2372 shared.harness_mcp_registry = harness_mcp_registry.clone();
2373 shared.session_record_retention = config.session_record_retention;
2374 shared.network_allowlist_catalog =
2375 RwLock::new(config.network_allowlist_catalog.clone());
2376 shared.bundle_catalog = RwLock::new(delivered_catalog);
2377 shared.delivery_store = Mutex::new(delivery_store);
2378 shared.context_catalog = RwLock::new(durable_context_catalog);
2379 shared.context_pack_store = Mutex::new(context_pack_store);
2380 shared.managed_spawn_replays = Mutex::new(
2381 managed_spawn_replays
2382 .into_iter()
2383 .map(|record| (record.idempotency_key.clone(), record))
2384 .collect(),
2385 );
2386 let shared = Arc::new(shared);
2387 if let Some(registry) = harness_mcp_registry {
2388 let weak = Arc::downgrade(&shared);
2389 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2390 Some(shared) => shared.publish_transient(event),
2391 None => false,
2392 }));
2393 }
2394 if shared.persistence_error().is_none() {
2395 shared.persist_state().map_err(|error| {
2396 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2397 })?;
2398 }
2399 Ok(Self {
2400 config,
2401 runtime,
2402 shared,
2403 events,
2404 state_path_lock,
2405 })
2406 }
2407
2408 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2409 NodeShutdownHandle {
2410 shared: Arc::clone(&self.shared),
2411 }
2412 }
2413
2414 #[cfg(feature = "fixture")]
2415 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2416 &self,
2417 ) -> SpawnManagedWorktreeV2FailureProbe {
2418 self.shared
2419 .fixture_spawn_managed_worktree_v2_failure_probe
2420 .clone()
2421 }
2422
2423 pub fn install_environment_profile(
2428 &mut self,
2429 profile: NodeEnvironmentProfile,
2430 ) -> Result<(), NodeServerError> {
2431 if !self.shared.enabled_providers.contains(profile.provider()) {
2432 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2433 profile.provider().clone(),
2434 ));
2435 }
2436 let (binding, native_profiles, materialization) = profile.into_parts();
2437 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2438 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2439 }
2440 let mut profiles = self
2441 .shared
2442 .environment_profiles
2443 .write()
2444 .unwrap_or_else(|poisoned| poisoned.into_inner());
2445 if profiles.contains_key(&binding.id) {
2446 return Err(NodeServerError::DuplicateEnvironmentProfile(
2447 binding.id.clone(),
2448 ));
2449 }
2450 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2451 return Err(NodeServerError::EnvironmentProfileCapacity {
2452 max: MAX_NODE_ENVIRONMENT_PROFILES,
2453 });
2454 }
2455 for native_id in binding.native_profile_ids() {
2456 if profiles.values().any(|current| {
2457 current.native_profile_ids().any(|current_id| current_id == native_id)
2458 }) {
2459 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2460 native_id.to_string(),
2461 ));
2462 }
2463 }
2464
2465 let mut installed = Vec::with_capacity(native_profiles.len());
2466 for native_profile in native_profiles {
2467 let native_id = native_profile.id().clone();
2468 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2469 for installed_id in installed.into_iter().rev() {
2470 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2471 }
2472 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2473 }
2474 installed.push(native_id);
2475 }
2476 if let Some(materialization) = materialization {
2477 self.shared
2478 .environment_materialization_profiles
2479 .write()
2480 .unwrap_or_else(|poisoned| poisoned.into_inner())
2481 .insert(binding.id.clone(), materialization);
2482 }
2483 profiles.insert(binding.id.clone(), binding);
2484 Ok(())
2485 }
2486
2487 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2489 if self.shared.session_environment_materializer.is_none() {
2490 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2491 }
2492 let mut catalog = self
2493 .shared
2494 .bundle_catalog
2495 .write()
2496 .unwrap_or_else(|poisoned| poisoned.into_inner());
2497 catalog
2498 .insert_idempotent(bundle)
2499 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2500 Ok(())
2501 }
2502
2503 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2504 let shutdown = self.shutdown_handle();
2505 let run = self.run();
2506 tokio::pin!(run);
2507 tokio::select! {
2508 result = &mut run => result,
2509 signal = wait_for_ctrl_signal() => {
2510 signal?;
2511 let shutdown_result = shutdown.request_shutdown().await;
2512 let run_result = run.await;
2513 shutdown_result.and(run_result)
2514 }
2515 }
2516 }
2517
2518 pub async fn run(self) -> Result<(), NodeServerError> {
2519 let NodeServer {
2520 config,
2521 mut runtime,
2522 shared,
2523 events,
2524 state_path_lock: _state_path_lock,
2525 } = self;
2526 shared.reconcile_materializations();
2527 shared.reconcile_managed_worktrees().await;
2528 shared.retire_unavailable_session_records().await;
2529 shared.reconcile_context_pack_exports().await;
2530 {
2531 let restore = Arc::clone(&shared);
2532 tokio::spawn(async move {
2533 sleep(Duration::from_secs(3)).await;
2534 restore.restore_journalled_sessions().await;
2535 loop {
2536 restore.refresh_session_journal();
2537 if restore.shutdown.load(Ordering::Acquire) { break; }
2538 sleep(Duration::from_secs(10)).await;
2539 }
2540 });
2541 }
2542 let endpoint = config.endpoint.clone();
2546 let call_home = config.call_home;
2547 let call_home_identity = config.call_home_identity.clone();
2548 let api_listen = config.api_listen;
2549 let bridge_listen = config.bridge_listen;
2550 let bridge_underlay = config.bridge_underlay;
2551 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2552 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2553 let accept_shared = Arc::clone(&shared);
2554 let api_shared = Arc::clone(&shared);
2555 let bridge_shared = Arc::clone(&shared);
2556 let shutdown_shared = Arc::clone(&shared);
2557 let shutdown_timeout = Duration::from_millis(
2558 config.runtime.provider_shutdown_timeout_ms.max(1),
2559 );
2560 let result = {
2561 let runtime_loop = drive_runtime_until_shutdown(
2562 &mut runtime,
2563 events,
2564 Arc::clone(&shared),
2565 shutdown_timeout,
2566 );
2567 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2568 let api_loop = http_api::run(api_listen, api_shared);
2569 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2570 tokio::pin!(runtime_loop);
2571 tokio::pin!(accept_loop);
2572 tokio::pin!(api_loop);
2573 tokio::pin!(bridge_loop);
2574 tokio::select! {
2575 runtime_result = &mut runtime_loop => {
2576 let shutdown_result = shutdown_shared
2577 .begin_shutdown()
2578 .await
2579 .map_err(NodeServerError::ShutdownDispatch);
2580 let (accept_result, api_result, bridge_result) =
2581 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2582 match runtime_result {
2583 Err(error) => Err(error),
2584 Ok(()) => shutdown_result
2585 .and(accept_result)
2586 .and(api_result.map_err(NodeServerError::HttpApi))
2587 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2588 }
2589 }
2590 accept_result = &mut accept_loop => {
2591 let shutdown_result = shutdown_shared
2592 .begin_shutdown()
2593 .await
2594 .map_err(NodeServerError::ShutdownDispatch);
2595 let (runtime_result, api_result, bridge_result) =
2596 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2597 match accept_result {
2598 Err(error) => Err(error),
2599 Ok(()) => shutdown_result
2600 .and(runtime_result)
2601 .and(api_result.map_err(NodeServerError::HttpApi))
2602 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2603 }
2604 }
2605 api_result = &mut api_loop => {
2606 let shutdown_result = shutdown_shared
2607 .begin_shutdown()
2608 .await
2609 .map_err(NodeServerError::ShutdownDispatch);
2610 let (runtime_result, accept_result, bridge_result) =
2611 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2612 match api_result {
2613 Err(error) => Err(NodeServerError::HttpApi(error)),
2614 Ok(()) => shutdown_result
2615 .and(runtime_result)
2616 .and(accept_result)
2617 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2618 }
2619 }
2620 bridge_result = &mut bridge_loop => {
2621 let shutdown_result = shutdown_shared
2622 .begin_shutdown()
2623 .await
2624 .map_err(NodeServerError::ShutdownDispatch);
2625 let (runtime_result, accept_result, api_result) =
2626 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2627 match bridge_result {
2628 Err(error) => Err(NodeServerError::BridgeApi(error)),
2629 Ok(()) => shutdown_result
2630 .and(runtime_result)
2631 .and(accept_result)
2632 .and(api_result.map_err(NodeServerError::HttpApi)),
2633 }
2634 }
2635 }
2636 };
2637 if let Ok(mut guard) = shared.delivery_store.lock() {
2644 *guard = None;
2645 }
2646 if let Ok(mut guard) = shared.context_pack_store.lock() {
2647 *guard = None;
2648 }
2649 result
2650 }
2651}
2652
2653async fn drive_runtime_until_shutdown(
2654 runtime: &mut NativeRuntime,
2655 events: EventSubscription,
2656 shared: Arc<NodeShared>,
2657 shutdown_timeout: Duration,
2658) -> Result<(), NodeServerError> {
2659 let _ = shared
2663 .shell_efficiency_profile
2664 .set(runtime.shell_efficiency_profile());
2665 let mut shutdown_started = None;
2666 let mut last_retention_sweep = Instant::now();
2667 let mut last_provisional_reap = Instant::now();
2668 loop {
2669 let runtime_tick_start = Instant::now();
2676 let tick_result = runtime.tick().await;
2677 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2678
2679 let event_drain_start = Instant::now();
2680 for outcome in &tick_result.command_outcomes {
2688 if let Err(error) = &outcome.result {
2689 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2690 tracing::warn!(
2691 command_id = outcome.command_id.0,
2692 cause = %error,
2693 "control command rejected"
2694 );
2695 shared.record_rejected_command(
2696 outcome.command_id,
2697 error.to_string(),
2698 error.is_unsupported_transport(),
2699 );
2700 }
2701 }
2702 let mut events_drained: u64 = 0;
2703 while let Ok(event) = events.try_recv() {
2704 events_drained += 1;
2705 let clean_exit = matches!(
2706 event.event,
2707 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2708 );
2709 let instance_id = event.instance_id;
2710 let generation = event.generation;
2711 if clean_exit {
2723 if let Some((record_id, session)) =
2724 shared.managed_record_export_target_for_instance(instance_id, generation)
2725 {
2726 let export_shared = Arc::clone(&shared);
2727 tokio::spawn(async move {
2728 export_shared
2729 .reconcile_context_pack_export_for_record(&record_id, session)
2730 .await;
2731 });
2732 }
2733 }
2734 shared.publish_control(event);
2735 }
2736 let event_drain_elapsed = event_drain_start.elapsed();
2737
2738 let publish_terminal_frames_start = Instant::now();
2739 shared.publish_terminal_frames();
2740 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2741
2742 shared
2748 .native_session_gauge
2749 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2750 *shared
2751 .runtime_tick_profile
2752 .lock()
2753 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2754
2755 let remainder_start = Instant::now();
2756 if shared.shutdown.load(Ordering::Acquire) {
2757 let started = *shutdown_started.get_or_insert_with(Instant::now);
2758 let snapshot = shared.handle.snapshot();
2759 let sessions_settled = snapshot.sessions.iter().all(|session| {
2760 !matches!(
2761 session.status,
2762 gate4agent_types::SessionStatus::Starting
2763 | gate4agent_types::SessionStatus::Running
2764 | gate4agent_types::SessionStatus::Stopping
2765 )
2766 });
2767 if runtime.active_native_sessions() == 0 && sessions_settled {
2768 return Ok(());
2769 }
2770 if started.elapsed() >= shutdown_timeout {
2771 return Err(NodeServerError::ShutdownTimedOut {
2772 active_native_sessions: runtime.active_native_sessions(),
2773 });
2774 }
2775 }
2776 let remainder_elapsed = remainder_start.elapsed();
2777
2778 {
2779 let mut profile = shared
2780 .drive_loop_profile
2781 .lock()
2782 .unwrap_or_else(|poisoned| poisoned.into_inner());
2783 profile.record_runtime_tick(runtime_tick_elapsed);
2784 profile.record_event_drain(event_drain_elapsed);
2785 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2786 profile.record_remainder(remainder_elapsed);
2787 profile.note_iteration(Instant::now());
2788 }
2789
2790 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2802 if tick_result.observations_applied == 0 && events_drained == 0 {
2803 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2804 }
2805
2806 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2811 shared.retire_unavailable_session_records().await;
2812 last_retention_sweep = Instant::now();
2813 }
2814 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2816 for address in shared.take_due_provisional_spawns() {
2817 shared.reap_provisional_spawn(address).await;
2818 }
2819 last_provisional_reap = Instant::now();
2820 }
2821
2822 let hot = shared
2825 .pty_input_marks
2826 .lock()
2827 .unwrap_or_else(|poisoned| poisoned.into_inner())
2828 .values()
2829 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2830 if hot {
2831 sleep(Duration::from_millis(1)).await;
2832 } else {
2833 tokio::select! {
2834 _ = sleep(Duration::from_millis(10)) => {}
2835 _ = shared.pty_input_wake.notified() => {}
2836 }
2837 }
2838 }
2839}
2840
2841#[derive(Clone)]
2842pub struct NodeShutdownHandle {
2843 shared: Arc<NodeShared>,
2844}
2845
2846impl NodeShutdownHandle {
2847 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2848 self.shared
2849 .begin_shutdown()
2850 .await
2851 .map_err(NodeServerError::ShutdownDispatch)
2852 }
2853}
2854
2855async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2856 #[cfg(windows)]
2857 {
2858 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2859 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2860 return tokio::select! {
2861 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2862 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2863 };
2864 }
2865 #[cfg(unix)]
2866 {
2867 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2868 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2869 tokio::select! {
2870 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872 }
2873 }
2874}
2875
2876fn shell_host_spec() -> AgentSpec {
2890 #[cfg(windows)]
2894 let (detect_command, program, fixed_args, process_name) = {
2895 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2896 r"C:\Windows\System32\cmd.exe".to_owned()
2897 });
2898 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2899 };
2900 #[cfg(not(windows))]
2901 let (detect_command, program, fixed_args, process_name) = (
2902 "bash".to_owned(),
2903 "/bin/bash".to_owned(),
2904 vec!["-i".to_owned()],
2905 "bash".to_owned(),
2906 );
2907 AgentSpec {
2908 id: AgentId::new("shell").expect("shell agent id"),
2909 revision: "hatchery:shell-pty-1".to_owned(),
2910 display_name: "Host shell".to_owned(),
2911 detection: DetectionSpec {
2912 command: detect_command,
2913 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2914 required_commands: Vec::new(),
2915 unsupported_platforms: Vec::new(),
2916 },
2917 launch: LaunchSpec {
2918 program,
2919 fixed_args,
2920 },
2921 expected_processes: vec![ProcessMatcher::Exact {
2922 name: process_name,
2923 }],
2924 prompt: PromptSpec {
2925 initial: InitialPromptMode::None,
2926 native_draft: None,
2927 },
2928 readiness: AgentReadinessSpec::default(),
2929 capabilities: AgentCapabilities {
2930 agent_commands: None,
2931 transports: AgentTransportCapabilities {
2932 pty: true,
2933 pty_adapter: None,
2934 pipe: None,
2935 acp: None,
2936 },
2937 adapters: AgentAdapterCapabilities::default(),
2938 },
2939 verification: SpecVerification::Gate4AgentVerified,
2940 }
2941}
2942
2943fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2944 let mut specs = vec![shell_host_spec()];
2945 specs.extend(
2946 builtin_registry()
2947 .iter()
2948 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2949 .cloned(),
2950 );
2951 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2952}
2953
2954fn provider_contract_manifest(
2955 catalog: &AgentRegistry,
2956) -> Result<
2957 (
2958 Vec<ProviderContractSupport>,
2959 Vec<ProviderAdapterContractSupport>,
2960 ),
2961 NodeServerError,
2962> {
2963 let mut provider_contracts = Vec::new();
2964 let mut provider_adapter_contracts = Vec::new();
2965 for spec in catalog.iter() {
2966 let provider = spec.id.clone();
2967 let revision = ProviderContractRevision::new(spec.revision.clone())
2968 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969 provider_contracts.push(ProviderContractSupport {
2970 provider: provider.clone(),
2971 revision,
2972 });
2973 for (family, binding) in declared_adapter_bindings(spec)? {
2974 let revision = AdapterContractRevision::new(binding.revision.clone())
2975 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2976 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2977 provider: provider.clone(),
2978 family,
2979 adapter_id: binding.id.clone(),
2980 revision,
2981 });
2982 }
2983 }
2984 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2985 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986 Ok((provider_contracts, provider_adapter_contracts))
2987}
2988
2989fn declared_adapter_bindings(
2990 spec: &AgentSpec,
2991) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2992 let mut bindings = Vec::new();
2993 push_declared_binding(
2994 &mut bindings,
2995 AdapterFamily::PtySemantic,
2996 spec.capabilities.transports.pty_adapter.as_ref(),
2997 )?;
2998 push_declared_binding(
2999 &mut bindings,
3000 AdapterFamily::Pipe,
3001 spec.capabilities.adapters.pty_sidecar.as_ref(),
3002 )?;
3003 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3004 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3005 gate4agent_types::PipeProtocol::SemanticNdjson
3006 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3007 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3008 });
3009 push_declared_binding(
3010 &mut bindings,
3011 AdapterFamily::Pipe,
3012 pipe_transport
3013 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3014 .map(|transport| &transport.adapter),
3015 )?;
3016 push_declared_binding(
3017 &mut bindings,
3018 AdapterFamily::Acp,
3019 spec.capabilities
3020 .transports
3021 .acp
3022 .as_ref()
3023 .map(|transport| &transport.adapter),
3024 )?;
3025 push_declared_binding(
3026 &mut bindings,
3027 AdapterFamily::Hook,
3028 spec.capabilities.adapters.hook.as_ref(),
3029 )?;
3030 push_declared_binding(
3031 &mut bindings,
3032 AdapterFamily::ManagedHook,
3033 spec.capabilities.adapters.managed_hook.as_ref(),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::OneShot,
3038 pipe_transport
3039 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3040 .map(|transport| &transport.adapter),
3041 )?;
3042 push_declared_binding(
3043 &mut bindings,
3044 AdapterFamily::OneShot,
3045 spec.capabilities.adapters.one_shot.as_ref(),
3046 )?;
3047 push_declared_binding(
3048 &mut bindings,
3049 AdapterFamily::History,
3050 spec.capabilities.adapters.history.as_ref(),
3051 )?;
3052 push_declared_binding(
3053 &mut bindings,
3054 AdapterFamily::Resume,
3055 spec.capabilities.adapters.resume.as_ref(),
3056 )?;
3057 push_declared_binding(
3058 &mut bindings,
3059 AdapterFamily::SessionOptions,
3060 spec.capabilities.adapters.session_options.as_ref(),
3061 )?;
3062 push_declared_binding(
3063 &mut bindings,
3064 AdapterFamily::CapabilityProbe,
3065 spec.capabilities.adapters.capability_probe.as_ref(),
3066 )?;
3067 Ok(bindings)
3068}
3069
3070fn push_declared_binding<'a>(
3071 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3072 family: AdapterFamily,
3073 binding: Option<&'a AdapterBinding>,
3074) -> Result<(), NodeServerError> {
3075 let Some(binding) = binding else {
3076 return Ok(());
3077 };
3078 if let Some((_, existing)) = bindings
3079 .iter()
3080 .find(|(existing_family, _)| *existing_family == family)
3081 {
3082 if *existing == binding {
3083 return Ok(());
3084 }
3085 return Err(NodeServerError::ProviderContractManifest(format!(
3086 "provider declares conflicting {family:?} adapter bindings"
3087 )));
3088 }
3089 bindings.push((family, binding));
3090 Ok(())
3091}
3092
3093fn merge_durable_state(
3094 configured: &[WorkspaceConfig],
3095 mut loaded: LoadedNodeState,
3096) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3097 let mut workspaces = configured
3098 .iter()
3099 .cloned()
3100 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3101 .collect::<BTreeMap<_, _>>();
3102 let mut roots = workspaces
3103 .values()
3104 .map(|workspace| {
3105 (
3106 platform::root_identity(&workspace.canonical_root),
3107 workspace.workspace_id.clone(),
3108 )
3109 })
3110 .collect::<BTreeMap<_, _>>();
3111 let mut warning = loaded
3112 .warning
3113 .take()
3114 .map(|message| session_registry::sanitized_persistence_summary(&message));
3115
3116 for (workspace_id, canonical_root) in loaded.workspaces {
3117 if let Some(existing) = workspaces.get(&workspace_id) {
3118 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3119 return Err(NodeServerError::DurableState(io::Error::new(
3120 io::ErrorKind::InvalidData,
3121 DURABLE_STATE_CONFLICT_ERROR,
3122 )));
3123 }
3124 continue;
3125 }
3126 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3127 return Err(NodeServerError::DurableState(io::Error::new(
3128 io::ErrorKind::InvalidData,
3129 DURABLE_STATE_CONFLICT_ERROR,
3130 )));
3131 }
3132 if !Path::new(&canonical_root).is_dir() {
3133 warning.get_or_insert_with(|| {
3134 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3135 });
3136 continue;
3137 }
3138 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3139 NodeServerError::DurableState(io::Error::new(
3140 io::ErrorKind::InvalidData,
3141 DURABLE_STATE_CONFLICT_ERROR,
3142 ))
3143 })?;
3144 roots.insert(
3145 platform::root_identity(&workspace.canonical_root),
3146 workspace.workspace_id.clone(),
3147 );
3148 workspaces.insert(workspace_id, workspace);
3149 }
3150
3151 for record in &mut loaded.records {
3152 match workspaces.get(&record.workspace_id) {
3153 Some(workspace)
3154 if platform::roots_equal(
3155 &workspace.canonical_root,
3156 windows_path_text(&record.canonical_root),
3157 ) => {}
3158 Some(_) => {
3159 return Err(NodeServerError::DurableState(io::Error::new(
3160 io::ErrorKind::InvalidData,
3161 DURABLE_STATE_CONFLICT_ERROR,
3162 )));
3163 }
3164 None => {
3165 record.state = ManagedSessionState::Unavailable;
3166 record.active_session = None;
3167 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3168 }
3169 }
3170 }
3171 Ok((workspaces.into_values().collect(), loaded.records, warning))
3172}
3173
3174struct RejectedCommandReason {
3177 message: String,
3178 unsupported_transport: bool,
3185 expires_at: Instant,
3186}
3187
3188struct NodeShared {
3189 handle: Gate4AgentHandle,
3190 access_token: String,
3191 node_id: NodeId,
3192 incarnation_id: NodeIncarnationId,
3193 started_at_unix_ms: u64,
3194 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3195 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3196 managed_worktree_profiles:
3197 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3198 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3199 enabled_providers: Vec<AgentId>,
3200 provider_runtime_statuses: ProviderRuntimeStatuses,
3201 provider_runtime_status_updates:
3202 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3203 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3204 provider_contracts: Vec<ProviderContractSupport>,
3205 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3206 spawn_profiles: SpawnProfileRegistry,
3207 environment_profiles:
3208 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3209 environment_materialization_profiles:
3210 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3211 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3219 #[cfg(feature = "dig2-station-probe")]
3223 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3224 bundle_catalog: RwLock<BundleCatalog>,
3225 delivery_store: Mutex<Option<DeliveryStore>>,
3226 context_pack_store: Mutex<Option<ContextPackStore>>,
3227 context_catalog: RwLock<ContextPackCatalog>,
3228 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3229 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3230 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3231 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3232 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3234 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3236 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3238 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3240 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3242 #[cfg(unix)]
3245 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3246 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3247 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3248 managed_spawn_replays:
3249 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3250 controller: Mutex<Option<ControllerLease>>,
3251 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3254 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3256 history: Mutex<NodeEventHistory>,
3257 event_tx: broadcast::Sender<NodeEventEnvelope>,
3258 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3267 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276 terminal_frame_watermarks:
3277 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3278 pty_input_wake: tokio::sync::Notify,
3280 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3283 next_connection_id: AtomicU64,
3284 next_instance_id: AtomicU64,
3285 next_command_id: AtomicU64,
3286 shutdown: AtomicBool,
3287 shutdown_notify: Notify,
3288 preauth_slots: Arc<Semaphore>,
3289 authenticated_slots: Arc<Semaphore>,
3290 inspection_slots: Arc<Semaphore>,
3291 mutation_gate: AsyncMutex<()>,
3292 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3293 session_records: Mutex<SessionRecords>,
3294 state_path: Option<PathBuf>,
3295 persistence_error: RwLock<Option<String>>,
3296 state_transaction: Mutex<()>,
3297 input_settle_timeout_ms: u64,
3298 drive_loop_profile: Mutex<DriveLoopProfiler>,
3302 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3308 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3320 native_session_gauge: AtomicUsize,
3327 connection_loop_iterations: AtomicU64,
3338 connection_events_sent: AtomicU64,
3339 rejected_commands_total: AtomicU64,
3348 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3368 drive_loop_iterations_total: AtomicU64,
3369 drive_loop_iterations_idle: AtomicU64,
3370 session_record_retention: SessionRecordRetentionConfig,
3374 retired_records_total: AtomicU64,
3380 #[cfg(feature = "fixture")]
3381 fixture_semantic_hook_policy: bool,
3382 #[cfg(feature = "fixture")]
3383 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3384}
3385
3386impl Drop for NodeShared {
3387 fn drop(&mut self) {
3388 SessionLifecycleHook::on_shutdown(self);
3389 }
3390}
3391
3392#[derive(Clone, Debug, Eq, PartialEq)]
3393struct SessionBinding {
3394 workspace_id: WorkspaceId,
3395 generation: SessionGeneration,
3396 runtime_policy: ProviderRuntimePolicy,
3397 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3398 record_id: Option<SessionRecordId>,
3399 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3400 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3401 bundle: Option<ResolvedBundleReceipt>,
3402 context: Option<ResolvedContextPackReceipt>,
3403 materialization_id: Option<MaterializationId>,
3404}
3405
3406#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3407enum SpawnRecordPolicy {
3408 ProviderIdentityOnly,
3409 Always,
3410}
3411
3412struct NativeEnvironmentSelectionGuard {
3413 control: Option<NativeLaunchProfileControl>,
3414 instance_id: AgentInstanceId,
3415}
3416
3417struct NativeInstanceOverlayGuard {
3418 control: Option<NativeLaunchProfileControl>,
3419 instance_id: AgentInstanceId,
3420}
3421
3422struct NativeHarnessMcpOverlayGuard {
3423 control: NativeLaunchProfileControl,
3424 instance_id: AgentInstanceId,
3425}
3426
3427impl NativeHarnessMcpOverlayGuard {
3428 fn retain(self) { std::mem::forget(self); }
3429}
3430
3431impl Drop for NativeHarnessMcpOverlayGuard {
3432 fn drop(&mut self) {
3433 self.control
3434 .clear_native_mcp_server_launch_overlay(self.instance_id);
3435 }
3436}
3437
3438impl NativeInstanceOverlayGuard {
3439 fn retain(mut self) {
3440 self.control = None;
3441 }
3442}
3443
3444impl Drop for NativeInstanceOverlayGuard {
3445 fn drop(&mut self) {
3446 if let Some(control) = self.control.as_ref() {
3447 control.clear_native_instance_launch_overlay(self.instance_id);
3448 }
3449 }
3450}
3451
3452enum PreparedNativeLaunchOverlay {
3453 Environment(NativeLaunchEnvironmentOverlay),
3454 Instance(NativeInstanceLaunchOverlay),
3455}
3456
3457impl NativeEnvironmentSelectionGuard {
3458 fn retain(mut self) {
3459 self.control = None;
3460 }
3461}
3462
3463impl Drop for NativeEnvironmentSelectionGuard {
3464 fn drop(&mut self) {
3465 if let Some(control) = self.control.as_ref() {
3466 control.clear_native_launch_profile_selection(self.instance_id);
3467 }
3468 }
3469}
3470
3471struct SessionMaterializationGuard<'a> {
3472 shared: &'a NodeShared,
3473 id: Option<MaterializationId>,
3474 provider_home: Option<PathBuf>,
3477}
3478
3479impl SessionMaterializationGuard<'_> {
3480 fn id(&self) -> Option<&MaterializationId> {
3481 self.id.as_ref()
3482 }
3483
3484 fn provider_home(&self) -> Option<&Path> {
3485 self.provider_home.as_deref()
3486 }
3487
3488 fn retain(mut self) {
3489 self.id = None;
3490 }
3491}
3492
3493impl Drop for SessionMaterializationGuard<'_> {
3494 fn drop(&mut self) {
3495 if let Some(id) = self.id.take() {
3496 let _ = self.shared.cleanup_materialization(&id);
3497 }
3498 }
3499}
3500
3501
3502trait SessionLifecycleHook {
3505 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3507 fn on_session_ended(&self, address: &SessionAddress);
3509 fn on_shutdown(&self);
3511 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3513 fn cleanup_session_materialization(
3515 &self,
3516 address: &SessionAddress,
3517 binding: &SessionBinding,
3518 ) -> Result<(), NodeFailure>;
3519}
3520
3521impl SessionLifecycleHook for NodeShared {
3522 fn on_session_ended(&self, address: &SessionAddress) {
3523 self.retire_session_grok_leader(address.session.instance_id);
3524 if !self.shutdown.load(Ordering::Acquire) {
3525 if let Some(file) = self.session_resume_journal() {
3526 crate::pty_resume::remove(&file, address.session.instance_id.0);
3527 }
3528 }
3529 if let Some(accounts) = self.m4a_accounts.as_ref() {
3530 let instance = address.session.instance_id;
3531 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3532 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3533 accounts.remove_session_file(nick);
3534 }
3535 }
3536 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3537 registry.revoke_session(address);
3538 }
3539 if let Some(control) = self.native_launch_profile_control.as_ref() {
3540 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3541 }
3542 }
3543
3544 fn on_shutdown(&self) {
3545 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3546 .into_values()
3547 .collect();
3548 for socket in leaders {
3549 crate::grok_leader::retire(&socket);
3550 }
3551 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3552 let instances = registry.shutdown();
3553 if let Some(control) = self.native_launch_profile_control.as_ref() {
3554 for instance_id in instances {
3555 control.clear_native_mcp_server_launch_overlay(instance_id);
3556 }
3557 }
3558 }
3559 }
3560
3561 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3562 if let Some(control) = self.native_launch_profile_control.as_ref() {
3563 control.clear_native_mcp_server_launch_overlay(instance_id);
3564 }
3565 }
3566
3567 fn cleanup_session_materialization(
3568 &self,
3569 address: &SessionAddress,
3570 binding: &SessionBinding,
3571 ) -> Result<(), NodeFailure> {
3572 self.cleanup_session_owned_materialization(address, binding)
3573 }
3574
3575 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3576 #[cfg(feature = "dig2-station-probe")]
3579 {
3580 crate::dig2_station_lease::release_lease_for_holder(
3581 &self.browser_station_leases,
3582 address.session.instance_id,
3583 );
3584 }
3585 if let Some(control) = self.native_launch_profile_control.as_ref() {
3586 control.clear_native_mcp_server_launch_overlay(
3587 address.session.instance_id,
3588 );
3589 }
3590 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3591 registry.revoke_session(address);
3592 }
3593 if removed
3594 .is_some_and(|binding| {
3595 binding.environment_profile.is_some()
3596 || binding.bundle.is_some()
3597 || binding.context.is_some()
3598 })
3599 {
3600 if let Some(control) = self.native_launch_profile_control.as_ref() {
3601 control.clear_native_launch_profile_selection(
3602 address.session.instance_id,
3603 );
3604 control.clear_native_instance_launch_overlay(
3605 address.session.instance_id,
3606 );
3607 }
3608 }
3609 }
3610}
3611
3612struct SpawnPrepareRequest<'r> {
3613 instance_id: AgentInstanceId,
3614 address: &'r SessionAddress,
3615 provider: &'r AgentId,
3616 mode: SessionMode,
3617 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3618 bundle: Option<&'r ResolvedBundleReceipt>,
3619 context: Option<&'r ResolvedContextPackReceipt>,
3620 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3621 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3622 approval_level: ApprovalLevel,
3623 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3624 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3625}
3626
3627struct PreparedSpawn<'a> {
3631 instance_overlay: Option<NativeInstanceOverlayGuard>,
3632 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3633 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3634 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3635 #[cfg(feature = "dig2-station-probe")]
3636 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3637 materialization_id: Option<MaterializationId>,
3638}
3639
3640impl PreparedSpawn<'_> {
3641 fn retain_session_guards(&mut self) {
3643 if let Some(selection) = self.environment_selection.take() {
3644 selection.retain();
3645 }
3646 if let Some(overlay) = self.instance_overlay.take() {
3647 overlay.retain();
3648 }
3649 if let Some(guard) = self.materialization_guard.take() {
3650 guard.retain();
3651 }
3652 }
3653
3654 fn retain_launch_guards(&mut self) {
3656 if let Some(overlay) = self.harness_mcp_overlay.take() {
3657 overlay.retain();
3658 }
3659 #[cfg(feature = "dig2-station-probe")]
3660 if let Some(guard) = self.browser_station_lease_guard.take() {
3661 guard.retain();
3662 }
3663 }
3664}
3665
3666trait SpawnPreparer {
3670 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3671 fn select_environment(
3673 &self,
3674 instance_id: AgentInstanceId,
3675 provider: &AgentId,
3676 mode: SessionMode,
3677 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3678 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3679 fn install_launch_overlay(
3681 &self,
3682 instance_id: AgentInstanceId,
3683 overlay: PreparedNativeLaunchOverlay,
3684 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3685}
3686
3687impl NodeShared {
3688 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3691 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3692 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3693 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3694 if let Some(file) = self.session_resume_journal() {
3695 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3696 }
3697 key
3698 }
3699
3700 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3701 let accounts = self.m4a_accounts.as_ref()?;
3702 let nick = self.m4a_nick_of(instance_id)?;
3703 match accounts.session_workdir(root, provider.as_str(), &nick) {
3704 Ok(dir) => dir,
3705 Err(error) => {
3706 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3707 None
3708 }
3709 }
3710 }
3711
3712 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3713 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3714 }
3715
3716 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3719 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3720 for record in records.records.values_mut() {
3721 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3722 record.display_name = nick.to_owned();
3723 }
3724 }
3725 }
3726
3727 fn m4a_session_overlay(
3730 &self,
3731 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3732 m4a_provider: &str,
3733 provider: &AgentId,
3734 instance_id: AgentInstanceId,
3735 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3736 session_key: &str,
3737 mode: SessionMode,
3738 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3739 let session_key = session_key.to_owned();
3740 if mode == SessionMode::Acp {
3741 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3742 }
3743 Ok( match accounts.provision(m4a_provider, &session_key) {
3744 Ok(account) => {
3745 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3746 let mut identity = account.env_mutations();
3747 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3748 let merged = match environment_overlay {
3749 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3750 .map(PreparedNativeLaunchOverlay::Instance),
3751 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3752 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3753 }
3754 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3755 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3756 }
3757 }
3758 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3759 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3760 let mut merged = merged;
3761 match accounts.write_session_file(&account) {
3762 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3763 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3764 Ok(args) if !args.is_empty() => {
3765 merged = match merged {
3766 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3767 .append_extra_args(args)
3768 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3769 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3770 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3771 .into_instance_with_extra_args(args)
3772 .map(PreparedNativeLaunchOverlay::Instance)
3773 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3774 };
3775 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3776 }
3777 Ok(_) => {}
3778 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3779 },
3780 }
3781 self.m4a_live
3782 .lock()
3783 .unwrap_or_else(|p| p.into_inner())
3784 .insert(account.nick.clone(), (instance_id, provider.clone()));
3785 self.name_record_after_nick(instance_id, &account.nick);
3786 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3787 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3789 Some(merged)
3790 }
3791 Err(error) => {
3792 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3793 environment_overlay
3794 }
3795 })
3796 }
3797}
3798
3799impl NodeShared {
3800 fn m4a_acp_mcp_overlay(
3803 &self,
3804 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3805 m4a_provider: &str,
3806 provider: &AgentId,
3807 instance_id: AgentInstanceId,
3808 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3809 session_key: &str,
3810 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3811 let Some(control) = self.native_launch_profile_control.as_ref() else {
3812 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3813 return Ok(environment_overlay);
3814 };
3815 let account = match accounts.provision(m4a_provider, session_key) {
3816 Ok(a) => a,
3817 Err(error) => {
3818 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3819 return Ok(environment_overlay);
3820 }
3821 };
3822 let session_file = match accounts.write_session_file(&account) {
3823 Ok(p) => p,
3824 Err(error) => {
3825 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3826 return Ok(environment_overlay);
3827 }
3828 };
3829 let env_entries: Vec<(OsString, OsString)> = account
3830 .env_mutations()
3831 .into_iter()
3832 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3833 .filter_map(|m| m.value.map(|v| (m.key, v)))
3834 .collect();
3835 let args: Vec<OsString> = vec![
3836 "--session-file".into(),
3837 session_file.into_os_string(),
3838 "mcp".into(),
3839 "--provider".into(),
3840 provider.as_str().into(),
3841 ];
3842 let overlay = NativeMcpServerLaunchOverlay::new(
3843 provider.clone(),
3844 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3845 accounts.bin().as_os_str().to_os_string(),
3846 args,
3847 env_entries,
3848 Vec::new(),
3849 );
3850 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3851 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3852 Err(error) => {
3853 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3854 return Ok(environment_overlay);
3855 }
3856 }
3857 self.m4a_live
3858 .lock()
3859 .unwrap_or_else(|p| p.into_inner())
3860 .insert(account.nick.clone(), (instance_id, provider.clone()));
3861 self.name_record_after_nick(instance_id, &account.nick);
3862 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3863 Ok(environment_overlay)
3864 }
3865}
3866
3867impl SpawnPreparer for NodeShared {
3868 fn select_environment(
3869 &self,
3870 instance_id: AgentInstanceId,
3871 provider: &AgentId,
3872 mode: SessionMode,
3873 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3874 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3875 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3876 }
3877
3878 fn install_launch_overlay(
3879 &self,
3880 instance_id: AgentInstanceId,
3881 overlay: PreparedNativeLaunchOverlay,
3882 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3883 self.install_prepared_launch_overlay(instance_id, overlay)
3884 }
3885
3886 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3887 let SpawnPrepareRequest {
3888 instance_id,
3889 address,
3890 provider,
3891 mode,
3892 environment_profile,
3893 bundle,
3894 context,
3895 managed_authority,
3896 harness_mcp,
3897 approval_level,
3898 network_allowlist,
3899 browser_profile_id,
3900 } = request;
3901 #[cfg(feature = "dig2-station-probe")]
3906 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3907 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3908 match acquire_lease(
3909 &self.browser_station_leases,
3910 profile_id,
3911 instance_id,
3912 unix_time_ms(),
3913 ) {
3914 Ok(guard) => Some(guard),
3915 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3916 return Err(failure(
3917 NodeFailureCode::BrowserStationProfileBusy,
3918 "dig2browser station profile is already leased by another session",
3919 ));
3920 }
3921 Err(BrowserStationLeaseError::CapacityExceeded) => {
3922 return Err(failure(
3923 NodeFailureCode::BrowserStationProfileBusy,
3924 "dig2browser station profile lease capacity exhausted",
3925 ));
3926 }
3927 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3928 return Err(failure(
3929 NodeFailureCode::BrowserStationProfileBusy,
3930 "session already holds a dig2browser station profile lease",
3931 ));
3932 }
3933 }
3934 } else {
3935 None
3936 };
3937 #[cfg(not(feature = "dig2-station-probe"))]
3938 let _ = browser_profile_id;
3939 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3940 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3949 {
3950 return Err(failure(
3951 NodeFailureCode::BindingMismatch,
3952 "harness MCP reservation does not match the exact PTY/ACP provider",
3953 ));
3954 }
3955 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3956 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3957 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3958 })?;
3959 let launch = prepared.launch();
3964 let mut env_entries = vec![
3965 (
3966 OsString::from(launch.endpoint_env.as_str()),
3967 prepared.endpoint().as_os_str().to_os_string(),
3968 ),
3969 (
3970 OsString::from(launch.token_env.as_str()),
3971 prepared.token().expose().into(),
3972 ),
3973 (
3974 OsString::from(launch.program_env.as_str()),
3975 prepared.helper_program().as_os_str().to_os_string(),
3976 ),
3977 ];
3978 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3979 env_entries.push(trace_entry);
3980 }
3981 if let Some(door) = crate::door::door_listen_addr() {
3982 env_entries.push((
3983 OsString::from("HATCHERY_NODE_DOOR"),
3984 OsString::from(door.to_string()),
3985 ));
3986 let session_key = format!(
3990 "pty-{}-{}-{}",
3991 self.incarnation_id,
3992 address.workspace_id.as_str(),
3993 address.session.instance_id.0
3994 );
3995 env_entries.push((
3996 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3997 OsString::from(session_key),
3998 ));
3999 }
4000 let overlay = NativeMcpServerLaunchOverlay::new(
4001 provider.clone(),
4002 launch.server_name.as_str(),
4003 prepared.helper_program().as_os_str().to_os_string(),
4004 launch.args.iter().map(OsString::from).collect(),
4005 env_entries,
4006 launch.scrub_env.iter().map(OsString::from).collect(),
4007 ).map_err(|_| failure(
4008 NodeFailureCode::HarnessMcpUnavailable,
4009 "harness MCP launch overlay is unavailable",
4010 ))?;
4011 control
4012 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4013 .map_err(|_| failure(
4014 NodeFailureCode::HarnessMcpUnavailable,
4015 "harness MCP launch overlay could not be installed",
4016 ))?;
4017 tracing::info!(
4023 node_id = %self.node_id,
4024 instance_id = ?instance_id,
4025 provider = %provider,
4026 mode = ?mode,
4027 "harness MCP overlay installed for spawn",
4028 );
4029 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4030 } else {
4031 None
4032 };
4033 let prepared_materialization = self.prepare_session_materialization(
4034 &address,
4035 &provider,
4036 mode,
4037 environment_profile,
4038 bundle,
4039 context,
4040 managed_authority.cloned(),
4041 )?;
4042 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4043 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4044 None => (None, None),
4045 };
4046 let materialization_id = materialization_guard
4047 .as_ref()
4048 .and_then(SessionMaterializationGuard::id)
4049 .cloned();
4050 let mut environment_selection = SpawnPreparer::select_environment(self,
4051 instance_id,
4052 &provider,
4053 mode,
4054 environment_profile,
4055 )?;
4056 let provider_home = materialization_guard
4059 .as_ref()
4060 .and_then(SessionMaterializationGuard::provider_home);
4061 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4062 &provider,
4063 mode,
4064 approval_level,
4065 network_allowlist,
4066 provider_home,
4067 )?;
4068 let mut network_overlay_args = network_overlay_args;
4069 {
4071 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4072 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4073 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4074 homes.extend(std::env::split_paths(&extra));
4075 }
4076 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4077 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4078 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4079 }
4080 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4081 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4082 }
4083 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4085 if let Some(file) = self.session_resume_journal() {
4086 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4087 instance_id: instance_id.0,
4088 provider: provider.as_str().to_owned(),
4089 workspace_id: address.workspace_id.as_str().to_owned(),
4090 provider_session_id: String::new(),
4091 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4092 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4093 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4094 cwd: String::new(),
4095 acp: mode == SessionMode::Acp,
4096 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4097 cols: 0,
4098 rows: 0,
4099 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4100 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4101 });
4102 if let Some(dir) = file.parent() {
4103 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4104 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106 provider: Some(provider.as_str().to_owned()),
4107 ..crate::session_meta::Meta::default()
4108 });
4109 crate::session_meta::revive(dir, instance_id.0);
4110 }
4111 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4112 }
4113 }
4114 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4115 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4116 }
4117 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4118 (prepared, args) if args.is_empty() => prepared,
4119 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4120 overlay.append_extra_args(args).map_err(|_| {
4121 failure(
4122 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4123 "network allowlist overlay could not merge with instance launch args",
4124 )
4125 })?;
4126 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4127 }
4128 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4129 Some(PreparedNativeLaunchOverlay::Instance(
4130 overlay
4131 .into_instance_with_extra_args(args)
4132 .map_err(|_| {
4133 failure(
4134 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135 "network allowlist overlay could not merge with environment overlay",
4136 )
4137 })?,
4138 ))
4139 }
4140 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4141 NativeInstanceLaunchOverlay::new(
4142 provider.clone(),
4143 match mode {
4144 SessionMode::Pty => TransportKind::Pty,
4145 SessionMode::Acp => TransportKind::Acp,
4146 SessionMode::Inline => TransportKind::Pipe,
4147 },
4148 Vec::new(),
4149 args,
4150 )
4151 .map_err(|_| {
4152 failure(
4153 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154 "network allowlist overlay is invalid for this transport",
4155 )
4156 })?,
4157 )),
4158 };
4159 #[cfg(unix)]
4162 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4164 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4165 });
4166 #[cfg(unix)]
4167 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4170 #[cfg(unix)]
4171 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4172 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4173 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4174 let uid = unsafe { libc_geteuid() };
4175 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4176 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4177 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4178 Some(vec![
4179 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4180 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4181 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4182 ])
4183 }
4184 _ => None,
4185 };
4186 #[cfg(not(unix))]
4187 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4188 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4189 (SessionMode::Pty, _, _, Some(identity)) => {
4190 let merged = match environment_overlay {
4191 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4192 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4193 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4194 }
4195 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4196 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4197 }
4198 }
4199 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4200 Some(merged)
4201 }
4202 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4203 let session_key = self.m4a_session_key(instance_id);
4204 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4205 }
4206 (SessionMode::Pty, Some(_), None, _) => {
4207 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4208 environment_overlay
4209 }
4210 _ => environment_overlay,
4211 };
4212 let mut instance_overlay = environment_overlay
4213 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4214 .transpose()?
4215 .flatten();
4216 Ok(PreparedSpawn {
4217 instance_overlay,
4218 environment_selection,
4219 materialization_guard,
4220 harness_mcp_overlay,
4221 #[cfg(feature = "dig2-station-probe")]
4222 browser_station_lease_guard,
4223 materialization_id,
4224 })
4225 }
4226}
4227
4228struct SessionRecords {
4229 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4230}
4231
4232struct SpawnIdempotencyCache {
4233 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4234}
4235
4236struct SpawnIdempotencyEntry {
4237 value: SpawnIdempotencyValue,
4238 expires_at: Instant,
4239}
4240
4241enum SpawnIdempotencyValue {
4242 Standard {
4243 spec: SpawnSpec,
4244 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4245 },
4246 Managed {
4247 request: ManagedWorktreeSpawnRequest,
4248 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4249 },
4250 HarnessMcp,
4251}
4252
4253enum DurableManagedSpawnReplayDecision {
4254 Reserved,
4255 PendingLinked,
4256 Committed(ManagedWorktreeSpawnReceipt),
4257}
4258
4259impl SpawnIdempotencyValue {
4260 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4261 match self {
4262 Self::Standard { result, .. } => result
4263 .as_ref()
4264 .ok()
4265 .and_then(|receipt| receipt.context.as_ref()),
4266 Self::Managed { result, .. } => result
4267 .as_ref()
4268 .ok()
4269 .and_then(|receipt| receipt.spawn.context.as_ref()),
4270 Self::HarnessMcp => None,
4271 }
4272 .is_some_and(|receipt| &receipt.id == context_id)
4273 }
4274}
4275
4276#[derive(Clone, Copy)]
4277struct ControllerLease {
4278 connection_id: u64,
4279 expires_at: Instant,
4280}
4281
4282struct NodeEventHistory {
4283 last_sequence: u64,
4284 replay_floor_sequence: u64,
4285 events: VecDeque<NodeEventEnvelope>,
4286 record_providers: BTreeMap<SessionRecordId, AgentId>,
4287 removed_record_providers: BTreeMap<u64, AgentId>,
4288}
4289
4290impl NodeEventHistory {
4291 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4292 Self {
4293 last_sequence: 0,
4294 replay_floor_sequence: 1,
4295 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4296 record_providers,
4297 removed_record_providers: BTreeMap::new(),
4298 }
4299 }
4300}
4301
4302impl NodeShared {
4303 fn session_resume_journal(&self) -> Option<PathBuf> {
4307 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4308 }
4309
4310 async fn restore_journalled_sessions(&self) {
4313 let Some(file) = self.session_resume_journal() else { return };
4314 if !crate::pty_resume::enabled() { return; }
4315 for e in crate::pty_resume::load(&file) {
4316 crate::pty_resume::remove(&file, e.instance_id);
4317 if !e.session_key.is_empty() {
4319 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4320 }
4321 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4322 if e.acp {
4323 let record_id = {
4324 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4325 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4326 };
4327 let cur = self.next_instance_id.load(Ordering::Acquire);
4328 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4329 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4330 let resumed = match record_id {
4331 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4332 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4333 };
4334 let r = match resumed {
4336 Ok(a) => Ok(a),
4337 Err(err) => {
4338 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4339 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4340 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4341 }
4342 };
4343 match r {
4344 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4345 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4346 }
4347 continue;
4348 }
4349 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4350 let cur = self.next_instance_id.load(Ordering::Acquire);
4352 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4353 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4354 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4355 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4356 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4357 }
4358 continue;
4359 }
4360 let folder = std::path::Path::new(&e.cwd);
4361 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4362 let name = match (in_session_dir, folder.file_name()) {
4363 (true, Some(n)) => n.to_string_lossy().into_owned(),
4364 _ => format!("{} #{}", e.provider, e.instance_id),
4365 };
4366 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4367 let record = match self.index_provider_session(ws, provider, identity, name) {
4368 Ok(r) => r,
4369 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4370 };
4371 let cur = self.next_instance_id.load(Ordering::Acquire);
4372 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4373 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4374 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4375 match r {
4376 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4377 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4378 }
4379 }
4380 }
4381
4382 fn refresh_session_journal(&self) {
4383 let Some(file) = self.session_resume_journal() else { return };
4384 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4385 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4386 crate::pty_resume::refresh(&file, &homes);
4387 let ids: Vec<(u64, String)> = {
4388 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4389 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4390 };
4391 for (inst, sid) in ids {
4392 crate::pty_resume::set_session_id(&file, inst, &sid);
4393 }
4394 }
4395
4396 fn session_grok_leader_socket(
4397 &self,
4398 provider: &AgentId,
4399 mode: SessionMode,
4400 instance_id: AgentInstanceId,
4401 ) -> Option<std::path::PathBuf> {
4402 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4403 return None;
4404 }
4405 let root = self
4406 .m4a_accounts
4407 .as_ref()
4408 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4409 .unwrap_or_else(std::env::temp_dir);
4410 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4411 if let Some(previous) = self
4412 .grok_leaders
4413 .lock()
4414 .unwrap_or_else(|p| p.into_inner())
4415 .insert(instance_id, socket.clone())
4416 {
4417 crate::grok_leader::retire(&previous);
4418 }
4419 Some(socket)
4420 }
4421
4422 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4424 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4425 if let Some(socket) = socket {
4426 crate::grok_leader::retire(&socket);
4427 }
4428 }
4429
4430 fn new_with_incarnation(
4431 handle: Gate4AgentHandle,
4432 access_token: String,
4433 node_id: NodeId,
4434 incarnation_id: NodeIncarnationId,
4435 workspaces: Vec<WorkspaceConfig>,
4436 enabled_providers: Vec<AgentId>,
4437 provider_runtime_statuses: ProviderRuntimeStatuses,
4438 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4439 provider_contracts: Vec<ProviderContractSupport>,
4440 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4441 spawn_profiles: SpawnProfileRegistry,
4442 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4443 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4444 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4445 state_path: Option<PathBuf>,
4446 records: Vec<ManagedSessionRecord>,
4447 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4448 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4449 materializations: Vec<MaterializationOwnershipRecord>,
4450 persistence_warning: Option<String>,
4451 input_settle_timeout_ms: u64,
4452 ) -> Self {
4453 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4454 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4455 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4456 let (harness_mcp_proxy_event_tx, _) =
4457 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4458 let record_providers = records
4459 .iter()
4460 .map(|record| (record.record_id.clone(), record.provider.clone()))
4461 .collect();
4462 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4463 .iter()
4464 .map(|workspace| {
4465 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4466 })
4467 .collect();
4468 for lease in &managed_worktrees {
4469 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4470 }
4471 let managed_worktree_profiles = workspaces.iter()
4472 .map(|workspace| (
4473 workspace.workspace_id.clone(),
4474 workspace.managed_worktree_profiles.clone(),
4475 ))
4476 .collect();
4477 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4478 managed_worktrees,
4479 managed_worktree_tombstones,
4480 ).expect("durable managed worktree registry was validated while loading");
4481 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4482 managed_worktrees.reattach_record_holders(
4483 &managed_worktree_record_holders(&records),
4484 unix_time_ms(),
4485 );
4486 Self {
4487 handle,
4488 access_token,
4489 node_id,
4490 incarnation_id,
4491 started_at_unix_ms: std::time::SystemTime::now()
4492 .duration_since(std::time::UNIX_EPOCH)
4493 .unwrap_or_default()
4494 .as_millis()
4495 .min(u64::MAX as u128) as u64,
4496 workspaces: RwLock::new(
4497 workspaces
4498 .into_iter()
4499 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4500 .collect(),
4501 ),
4502 worktree_service_modes,
4503 managed_worktree_profiles,
4504 managed_worktrees: Mutex::new(managed_worktrees),
4505 enabled_providers,
4506 provider_runtime_statuses,
4507 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4508 provider_runtime_monitor,
4509 provider_contracts,
4510 provider_adapter_contracts,
4511 spawn_profiles,
4512 environment_profiles: RwLock::new(BTreeMap::new()),
4513 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4514 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4515 #[cfg(feature = "dig2-station-probe")]
4516 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4517 bundle_catalog: RwLock::new(BundleCatalog::default()),
4518 delivery_store: Mutex::new(None),
4519 context_pack_store: Mutex::new(None),
4520 context_catalog: RwLock::new(ContextPackCatalog::default()),
4521 native_launch_profile_control,
4522 harness_mcp_registry: None,
4523 native_session_catalog,
4524 session_environment_materializer,
4525 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4526 m4a_live: Mutex::new(BTreeMap::new()),
4527 m4a_keys: Mutex::new(BTreeMap::new()),
4528 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4529 grok_leaders: Mutex::new(BTreeMap::new()),
4530 #[cfg(unix)]
4531 #[cfg(unix)]
4532 signer_proxy: std::sync::OnceLock::new(),
4533 materializations: Mutex::new(
4534 materializations
4535 .into_iter()
4536 .map(|record| (record.id().clone(), record))
4537 .collect(),
4538 ),
4539 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4540 entries: BTreeMap::new(),
4541 }),
4542 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4543 controller: Mutex::new(None),
4544 provisional_spawns: Mutex::new(BTreeMap::new()),
4545 pending_acp_options: Mutex::new(BTreeMap::new()),
4546 history: Mutex::new(NodeEventHistory::new(record_providers)),
4547 event_tx,
4548 terminal_event_tx,
4549 agent_stream_event_tx,
4550 harness_mcp_proxy_event_tx,
4551 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4552 pty_input_wake: tokio::sync::Notify::new(),
4553 pty_input_marks: Mutex::new(BTreeMap::new()),
4554 next_connection_id: AtomicU64::new(1),
4555 next_instance_id: AtomicU64::new(1),
4556 next_command_id: AtomicU64::new(1),
4557 shutdown: AtomicBool::new(false),
4558 shutdown_notify: Notify::new(),
4559 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4560 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4561 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4562 mutation_gate: AsyncMutex::new(()),
4563 session_bindings: Mutex::new(BTreeMap::new()),
4564 session_records: Mutex::new(SessionRecords {
4565 records: records
4566 .into_iter()
4567 .map(|record| (record.record_id.clone(), record))
4568 .collect(),
4569 }),
4570 state_path,
4571 persistence_error: RwLock::new(
4572 persistence_warning
4573 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4574 ),
4575 state_transaction: Mutex::new(()),
4576 input_settle_timeout_ms,
4577 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4578 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4579 shell_efficiency_profile: OnceLock::new(),
4580 native_session_gauge: AtomicUsize::new(0),
4581 connection_loop_iterations: AtomicU64::new(0),
4582 connection_events_sent: AtomicU64::new(0),
4583 rejected_commands_total: AtomicU64::new(0),
4584 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4585 drive_loop_iterations_total: AtomicU64::new(0),
4586 drive_loop_iterations_idle: AtomicU64::new(0),
4587 session_record_retention: SessionRecordRetentionConfig::default(),
4588 retired_records_total: AtomicU64::new(0),
4589 #[cfg(feature = "fixture")]
4590 fixture_semantic_hook_policy: false,
4591 #[cfg(feature = "fixture")]
4592 fixture_spawn_managed_worktree_v2_failure_probe:
4593 SpawnManagedWorktreeV2FailureProbe::default(),
4594 }
4595 }
4596
4597 #[cfg(test)]
4598 fn new(
4599 handle: Gate4AgentHandle,
4600 access_token: String,
4601 node_id: NodeId,
4602 workspaces: Vec<WorkspaceConfig>,
4603 enabled_providers: Vec<AgentId>,
4604 ) -> Self {
4605 Self::new_with_incarnation(
4606 handle,
4607 access_token,
4608 node_id,
4609 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4610 workspaces,
4611 enabled_providers,
4612 ProviderRuntimeStatuses::default(),
4613 None,
4614 Vec::new(),
4615 Vec::new(),
4616 SpawnProfileRegistry::default(),
4617 None,
4618 None,
4619 None,
4620 None,
4621 Vec::new(),
4622 Vec::new(),
4623 Vec::new(),
4624 Vec::new(),
4625 None,
4626 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4627 )
4628 }
4629
4630 fn persistence_error(&self) -> Option<String> {
4631 self.persistence_error
4632 .read()
4633 .unwrap_or_else(|poisoned| poisoned.into_inner())
4634 .clone()
4635 }
4636
4637 async fn admit_provider_runtime(
4638 &self,
4639 provider: &AgentId,
4640 requirement: ProviderRuntimeRequirement,
4641 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4642 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4643 let pipe_transport = monitor.supports_pipe_transport(provider);
4644 let acp_transport = monitor.supports_acp_transport(provider);
4645 let provider = provider.clone();
4646 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4647 let (status, admission) = timeout(
4648 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4649 refresh,
4650 )
4651 .await
4652 .map_err(|_| failure(
4653 NodeFailureCode::BackendBusy,
4654 "provider runtime probe exceeded its bounded deadline",
4655 ))?
4656 .map_err(|_| failure(
4657 NodeFailureCode::BackendOperationFailed,
4658 "provider runtime probe task failed",
4659 ))?;
4660 if let Some(status) = status {
4661 self
4662 .provider_runtime_status_updates
4663 .write()
4664 .unwrap_or_else(|poisoned| poisoned.into_inner())
4665 .insert(status.provider().clone(), status);
4666 }
4667 admission.and_then(|policy| {
4668 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4669 })
4670 } else {
4671 crate::provider_runtime::admit_status(
4677 &self.provider_runtime_statuses,
4678 provider,
4679 false,
4680 false,
4681 requirement,
4682 )
4683 };
4684 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4685 admission.map_err(|error| match error {
4686 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4687 NodeFailureCode::BackendOperationFailed,
4688 "provider launcher is unavailable",
4689 ),
4690 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4691 NodeFailureCode::UnsupportedCapability,
4692 "provider semantic capability is not verified",
4693 ),
4694 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4695 NodeFailureCode::BackendBusy,
4696 "provider runtime probe is already in progress",
4697 ),
4698 })
4699 }
4700
4701 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4707 match self.provider_runtime_monitor.as_deref() {
4708 Some(monitor) => monitor.supports_pipe_transport(provider),
4709 None => false,
4710 }
4711 }
4712
4713 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4719 match self.provider_runtime_monitor.as_deref() {
4720 Some(monitor) => monitor.supports_acp_transport(provider),
4721 None => false,
4722 }
4723 }
4724
4725 fn admit_declared_hook_semantics(
4741 &self,
4742 provider: &AgentId,
4743 policy: ProviderRuntimePolicy,
4744 ) -> ProviderRuntimePolicy {
4745 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4746 return policy;
4747 }
4748 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4749 contract.provider == *provider && contract.family == AdapterFamily::Hook
4750 });
4751 if !declares_hook {
4752 return policy;
4753 }
4754 ProviderRuntimePolicy::new(
4755 policy.raw_pty_lifecycle,
4756 policy.semantic_readiness,
4757 policy.structured_prompt,
4758 policy.provider_session_identity,
4759 policy.semantic_resume,
4760 true,
4761 )
4762 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4763 }
4764
4765 fn resolve_network_allowlist_overlay_args(
4780 &self,
4781 provider: &AgentId,
4782 mode: SessionMode,
4783 approval_level: ApprovalLevel,
4784 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4785 provider_home: Option<&Path>,
4786 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4787 let Some(allowlist_id) = network_allowlist else {
4788 return Ok(Vec::new());
4789 };
4790 let catalog = self
4791 .network_allowlist_catalog
4792 .read()
4793 .unwrap_or_else(|poisoned| poisoned.into_inner());
4794 let Some(entry) = catalog.get(allowlist_id) else {
4795 return Err(failure(
4796 NodeFailureCode::UnknownNetworkAllowlist,
4797 "spawn network allowlist is unavailable on this node",
4798 ));
4799 };
4800
4801 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4803 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4804 |message| {
4805 failure(
4806 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4807 &message,
4808 )
4809 },
4810 )?;
4811 if !matches!(mode, SessionMode::Pty) {
4812 return Err(failure(
4813 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4814 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4815 ));
4816 }
4817 let Some(home) = provider_home else {
4818 return Err(failure(
4819 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4821 ));
4822 };
4823 let domains =
4824 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4825 if domains.is_empty() {
4826 return Err(failure(
4827 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4828 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4829 ));
4830 }
4831 let settings_path = home.join(
4832 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4833 );
4834 let json =
4835 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4836 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4837 |_| {
4838 failure(
4839 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840 "Claude station network settings sidecar could not be written under ProviderHome",
4841 )
4842 },
4843 )?;
4844 let overlay =
4845 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4846 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4847 }
4848
4849 let Some(native) = entry.provider_native.as_ref() else {
4850 return Ok(Vec::new());
4851 };
4852 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4853 provider.as_str(),
4854 approval_level,
4855 native,
4856 )
4857 .map_err(|message| {
4858 failure(
4859 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860 &message,
4861 )
4862 })?;
4863 if overlay.is_empty() {
4864 return Ok(Vec::new());
4865 }
4866 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4867 return Err(failure(
4868 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869 "Codex network_access -c overlay requires PTY or ACP transport",
4870 ));
4871 }
4872 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4873 }
4874
4875 fn resolve_codex_network_access_overlay_args(
4877 &self,
4878 provider: &AgentId,
4879 mode: SessionMode,
4880 approval_level: ApprovalLevel,
4881 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4882 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4883 self.resolve_network_allowlist_overlay_args(
4884 provider,
4885 mode,
4886 approval_level,
4887 network_allowlist,
4888 None,
4889 )
4890 }
4891
4892 fn resolve_environment_profile(
4893 &self,
4894 resolved: &ResolvedSpawnSpec,
4895 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4896 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4902 let catalog = self
4903 .network_allowlist_catalog
4904 .read()
4905 .unwrap_or_else(|poisoned| poisoned.into_inner());
4906 let Some(entry) = catalog.get(allowlist_id) else {
4907 return Err(failure(
4908 NodeFailureCode::UnknownNetworkAllowlist,
4909 "spawn network allowlist is unavailable on this node",
4910 ));
4911 };
4912 if let Some(native) = entry.provider_native.as_ref() {
4915 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4916 resolved.provider.as_str(),
4917 resolved.approval_level,
4918 native,
4919 ) {
4920 return Err(failure(
4921 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4922 &message,
4923 ));
4924 }
4925 }
4926 }
4927 #[cfg(feature = "dig2-station-probe")]
4936 if resolved.browser_profile_id.is_some() {
4937 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4938 match probe_station_reachable() {
4939 Ok(()) => {}
4940 Err(StationProbeError::UnsupportedPlatform)
4941 | Err(StationProbeError::InvalidPipeSuffix) => {
4942 return Err(failure(
4943 NodeFailureCode::BrowserStationProbeUnavailable,
4944 "dig2browser station probe unavailable on this platform",
4945 ));
4946 }
4947 Err(StationProbeError::Unreachable) => {
4948 return Err(failure(
4949 NodeFailureCode::BrowserStationUnreachable,
4950 "dig2browser station named-pipe is unreachable",
4951 ));
4952 }
4953 }
4954 }
4955 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4956 return Ok(None);
4957 };
4958 let profiles = self
4959 .environment_profiles
4960 .read()
4961 .unwrap_or_else(|poisoned| poisoned.into_inner());
4962 let binding = profiles.get(profile_id).ok_or_else(|| {
4963 failure(
4964 NodeFailureCode::UnknownEnvironmentProfile,
4965 "spawn environment profile is unavailable on this node",
4966 )
4967 })?;
4968 if binding.provider != resolved.provider
4969 || binding.native_profile_id(resolved.mode).is_none()
4970 {
4971 return Err(failure(
4972 NodeFailureCode::EnvironmentProfileBindingMismatch,
4973 "spawn environment profile does not match the provider and mode",
4974 ));
4975 }
4976 Ok(Some(ResolvedEnvironmentProfileReceipt {
4980 profile_id: binding.id.clone(),
4981 profile_revision: binding.revision.clone(),
4982 network_allowlist: resolved.network_allowlist.clone(),
4983 browser_profile_id: resolved.browser_profile_id.clone(),
4984 }))
4985 }
4986
4987 fn resolve_bundle(
4988 &self,
4989 resolved: &ResolvedSpawnSpec,
4990 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4991 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4992 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4993 return Ok(None);
4994 };
4995 let catalog = self.bundle_catalog
4996 .read()
4997 .unwrap_or_else(|poisoned| poisoned.into_inner());
4998 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4999 failure(
5000 NodeFailureCode::UnknownBundle,
5001 "spawn bundle is unavailable on this node",
5002 )
5003 })?;
5004 self.bundle_layout(
5005 &resolved.provider,
5006 resolved.mode,
5007 environment_profile,
5008 bundle,
5009 )?;
5010 Ok(Some(bundle.receipt()))
5011 }
5012
5013 fn begin_delivery_stage(
5014 &self,
5015 manifest: DeliveryBundleManifestV2,
5016 ) -> Result<NodeResponse, NodeFailure> {
5017 let mut store = self
5018 .delivery_store
5019 .lock()
5020 .unwrap_or_else(|poisoned| poisoned.into_inner());
5021 let store = store.as_mut().ok_or_else(|| {
5022 failure(
5023 NodeFailureCode::DeliveryStageStorageFailed,
5024 "node delivery store is unavailable",
5025 )
5026 })?;
5027 let begun = store.begin(manifest).map_err(delivery_failure)?;
5028 Ok(NodeResponse::DeliveryStageBegun {
5029 stage_id: begun.stage_id,
5030 manifest_digest: begun.manifest_digest,
5031 missing_blobs: begun.missing_blobs,
5032 })
5033 }
5034
5035 fn put_delivery_blob_chunk(
5036 &self,
5037 stage_id: DeliveryStageId,
5038 blob_digest: DeliveryBlobDigestV1,
5039 offset: u64,
5040 chunk_hex: DeliveryBlobChunkHexV1,
5041 ) -> Result<NodeResponse, NodeFailure> {
5042 let mut store = self
5043 .delivery_store
5044 .lock()
5045 .unwrap_or_else(|poisoned| poisoned.into_inner());
5046 let store = store.as_mut().ok_or_else(|| {
5047 failure(
5048 NodeFailureCode::DeliveryStageStorageFailed,
5049 "node delivery store is unavailable",
5050 )
5051 })?;
5052 let accepted = store
5053 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5054 .map_err(delivery_failure)?;
5055 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5056 stage_id: accepted.stage_id,
5057 blob_digest: accepted.blob_digest,
5058 next_offset: accepted.next_offset,
5059 })
5060 }
5061
5062 fn commit_delivery_stage(
5063 &self,
5064 stage_id: DeliveryStageId,
5065 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5066 let mut store = self
5067 .delivery_store
5068 .lock()
5069 .unwrap_or_else(|poisoned| poisoned.into_inner());
5070 let store = store.as_mut().ok_or_else(|| {
5071 failure(
5072 NodeFailureCode::DeliveryStageStorageFailed,
5073 "node delivery store is unavailable",
5074 )
5075 })?;
5076 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5077 let mut catalog = self
5078 .bundle_catalog
5079 .write()
5080 .unwrap_or_else(|poisoned| poisoned.into_inner());
5081 let mut next_catalog = catalog.clone();
5082 next_catalog
5083 .insert_idempotent(prepared.bundle().clone())
5084 .map_err(|_| {
5085 failure(
5086 NodeFailureCode::DeliveryStageConflict,
5087 "delivery bundle identity conflicts with installed content",
5088 )
5089 })?;
5090 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5091 *catalog = next_catalog;
5092 Ok(receipt)
5093 }
5094
5095 fn abort_delivery_stage(
5096 &self,
5097 stage_id: &DeliveryStageId,
5098 ) -> Result<(), NodeFailure> {
5099 let mut store = self
5100 .delivery_store
5101 .lock()
5102 .unwrap_or_else(|poisoned| poisoned.into_inner());
5103 let store = store.as_mut().ok_or_else(|| {
5104 failure(
5105 NodeFailureCode::DeliveryStageStorageFailed,
5106 "node delivery store is unavailable",
5107 )
5108 })?;
5109 store.abort(stage_id).map_err(delivery_failure)
5110 }
5111
5112 fn resolve_context(
5113 &self,
5114 resolved: &ResolvedSpawnSpec,
5115 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5116 let Some(context_id) = resolved.context_id.as_ref() else {
5117 return Ok(None);
5118 };
5119 self.context_catalog
5120 .read()
5121 .unwrap_or_else(|poisoned| poisoned.into_inner())
5122 .get(context_id)
5123 .map(|context| context.receipt().clone())
5124 .ok_or_else(|| {
5125 failure(
5126 NodeFailureCode::UnknownContextPack,
5127 "spawn context pack is unavailable on this node",
5128 )
5129 })
5130 .map(Some)
5131 }
5132
5133 fn bundle_layout(
5134 &self,
5135 provider: &AgentId,
5136 mode: SessionMode,
5137 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5138 bundle: &NodeBundle,
5139 ) -> Result<BundleProviderLayout, NodeFailure> {
5140 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5141 failure(
5142 NodeFailureCode::BundleBindingMismatch,
5143 "spawn bundle does not match the provider and mode",
5144 )
5145 })?;
5146 if layout != BundleProviderLayout::Codex {
5147 return Ok(layout);
5148 }
5149 let profile = environment_profile.and_then(|receipt| {
5150 let profiles = self
5151 .environment_profiles
5152 .read()
5153 .unwrap_or_else(|poisoned| poisoned.into_inner());
5154 let binding = profiles.get(&receipt.profile_id)?;
5155 if binding.revision != receipt.profile_revision
5156 || &binding.provider != provider
5157 || binding.native_profile_id(mode).is_none()
5158 {
5159 return None;
5160 }
5161 self.environment_materialization_profiles
5162 .read()
5163 .unwrap_or_else(|poisoned| poisoned.into_inner())
5164 .get(&receipt.profile_id)
5165 .cloned()
5166 });
5167 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5168 return Err(failure(
5169 NodeFailureCode::BundleBindingMismatch,
5170 "Codex bundle requires an exact isolated CODEX_HOME profile",
5171 ));
5172 }
5173 Ok(layout)
5174 }
5175
5176 fn select_environment_profile(
5177 &self,
5178 instance_id: AgentInstanceId,
5179 provider: &AgentId,
5180 mode: SessionMode,
5181 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5182 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5183 let Some(environment_profile) = environment_profile else {
5184 return Ok(None);
5185 };
5186 let native_profile_id = {
5187 let profiles = self
5188 .environment_profiles
5189 .read()
5190 .unwrap_or_else(|poisoned| poisoned.into_inner());
5191 let binding = profiles
5192 .get(&environment_profile.profile_id)
5193 .ok_or_else(|| {
5194 failure(
5195 NodeFailureCode::UnknownEnvironmentProfile,
5196 "session environment profile is unavailable on this node",
5197 )
5198 })?;
5199 if &binding.revision != &environment_profile.profile_revision
5200 || &binding.provider != provider
5201 {
5202 return Err(failure(
5203 NodeFailureCode::EnvironmentProfileBindingMismatch,
5204 "session environment profile revision or provider changed",
5205 ));
5206 }
5207 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5208 failure(
5209 NodeFailureCode::EnvironmentProfileBindingMismatch,
5210 "session environment profile does not support this mode",
5211 )
5212 })?
5213 };
5214 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5215 failure(
5216 NodeFailureCode::BackendOperationFailed,
5217 "native environment profile controller is unavailable",
5218 )
5219 })?;
5220 control
5221 .select_native_launch_profile(instance_id, native_profile_id)
5222 .map_err(|_| {
5223 failure(
5224 NodeFailureCode::BackendOperationFailed,
5225 "native environment profile selection failed",
5226 )
5227 })?;
5228 Ok(Some(NativeEnvironmentSelectionGuard {
5229 control: Some(control),
5230 instance_id,
5231 }))
5232 }
5233
5234 fn install_prepared_launch_overlay(
5235 &self,
5236 instance_id: AgentInstanceId,
5237 overlay: PreparedNativeLaunchOverlay,
5238 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5239 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5240 failure(
5241 NodeFailureCode::BackendOperationFailed,
5242 "native launch profile controller is unavailable",
5243 )
5244 })?;
5245 match overlay {
5246 PreparedNativeLaunchOverlay::Environment(overlay) => {
5247 control
5248 .install_native_launch_environment_overlay(instance_id, overlay)
5249 .map_err(|_| failure(
5250 NodeFailureCode::BackendOperationFailed,
5251 "session environment overlay installation failed",
5252 ))?;
5253 Ok(None)
5254 }
5255 PreparedNativeLaunchOverlay::Instance(overlay) => {
5256 control
5257 .install_native_instance_launch_overlay(instance_id, overlay)
5258 .map_err(|_| failure(
5259 NodeFailureCode::BundleBindingMismatch,
5260 "session bundle launch overlay installation failed",
5261 ))?;
5262 Ok(Some(NativeInstanceOverlayGuard {
5263 control: Some(control),
5264 instance_id,
5265 }))
5266 }
5267 }
5268 }
5269
5270 fn environment_materialization_profile(
5271 &self,
5272 environment_profile: &ResolvedEnvironmentProfileReceipt,
5273 ) -> Option<NodeSessionMaterializationProfile> {
5274 self.environment_materialization_profiles
5275 .read()
5276 .unwrap_or_else(|poisoned| poisoned.into_inner())
5277 .get(&environment_profile.profile_id)
5278 .cloned()
5279 }
5280
5281 fn materialization_profile(
5282 &self,
5283 provider: &AgentId,
5284 mode: SessionMode,
5285 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5286 bundle: Option<&ResolvedBundleReceipt>,
5287 context: Option<&ResolvedContextPackReceipt>,
5288 ) -> Result<Option<(
5289 NodeSessionMaterializationProfile,
5290 bool,
5291 Option<BundleProviderLayout>,
5292 )>, NodeFailure> {
5293 let environment = environment_profile
5294 .and_then(|receipt| self.environment_materialization_profile(receipt));
5295 let (bundle, bundle_layout) = match bundle {
5296 Some(receipt) => {
5297 let catalog = self.bundle_catalog.read()
5298 .unwrap_or_else(|poisoned| poisoned.into_inner());
5299 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5300 NodeFailureCode::UnknownBundle,
5301 "session bundle is unavailable on this node",
5302 ))?;
5303 if installed.receipt() != *receipt {
5304 return Err(failure(
5305 NodeFailureCode::BundleBindingMismatch,
5306 "session bundle revision or digest changed",
5307 ));
5308 }
5309 let layout = self.bundle_layout(
5310 provider,
5311 mode,
5312 environment_profile,
5313 installed,
5314 )?;
5315 (Some(installed.clone()), Some(layout))
5316 }
5317 None => (None, None),
5318 };
5319 let has_environment_overlay = environment.is_some() || context.is_some();
5320 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5321 (None, None, None) => None,
5322 (Some(profile), None, None) => Some(Ok(profile)),
5323 (None, Some(bundle), Some(layout)) => {
5324 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5325 }
5326 (Some(profile), Some(bundle), Some(layout)) => {
5327 Some(profile.with_bundle(bundle, layout))
5328 }
5329 _ => {
5330 return Err(failure(
5331 NodeFailureCode::BundleBindingMismatch,
5332 "session bundle provider layout is unavailable",
5333 ));
5334 }
5335 };
5336 let profile = profile
5337 .transpose()
5338 .map_err(|_| failure(
5339 NodeFailureCode::BundleMaterializationFailed,
5340 "session bundle materialization profile is invalid",
5341 ))?;
5342 let profile = match (profile, context) {
5343 (Some(profile), Some(receipt)) => {
5344 let catalog = self.context_catalog
5345 .read()
5346 .unwrap_or_else(|poisoned| poisoned.into_inner());
5347 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348 NodeFailureCode::UnknownContextPack,
5349 "session context pack is unavailable on this node",
5350 ))?;
5351 if pack.receipt() != receipt {
5352 return Err(failure(
5353 NodeFailureCode::ContextPackMaterializationFailed,
5354 "session context pack identity changed",
5355 ));
5356 }
5357 profile.with_context(pack)
5358 }
5359 (None, Some(receipt)) => {
5360 let catalog = self.context_catalog
5361 .read()
5362 .unwrap_or_else(|poisoned| poisoned.into_inner());
5363 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5364 NodeFailureCode::UnknownContextPack,
5365 "session context pack is unavailable on this node",
5366 ))?;
5367 if pack.receipt() != receipt {
5368 return Err(failure(
5369 NodeFailureCode::ContextPackMaterializationFailed,
5370 "session context pack identity changed",
5371 ));
5372 }
5373 NodeSessionMaterializationProfile::from_context(pack)
5374 }
5375 (Some(profile), None) => Ok(profile),
5376 (None, None) => return Ok(None),
5377 }
5378 .map_err(|_| failure(
5379 NodeFailureCode::ContextPackMaterializationFailed,
5380 "session context pack materialization profile is invalid",
5381 ))?;
5382 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5383 }
5384
5385 fn prepare_session_materialization<'a>(
5386 &'a self,
5387 address: &SessionAddress,
5388 provider: &AgentId,
5389 mode: SessionMode,
5390 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5391 bundle: Option<&ResolvedBundleReceipt>,
5392 context: Option<&ResolvedContextPackReceipt>,
5393 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5394 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5395 let Some((profile, has_environment_overlay, bundle_layout)) =
5396 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5397 else {
5398 return Ok(None);
5399 };
5400 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5401 failure(
5402 NodeFailureCode::BackendOperationFailed,
5403 "session environment materializer is unavailable",
5404 )
5405 })?;
5406 let materialization_failure_code = if context.is_some() {
5407 NodeFailureCode::ContextPackMaterializationFailed
5408 } else if bundle.is_some() {
5409 NodeFailureCode::BundleMaterializationFailed
5410 } else {
5411 NodeFailureCode::BackendOperationFailed
5412 };
5413 let id = MaterializationId::new(format!(
5414 "mat-{}-{}-{}",
5415 self.incarnation_id,
5416 address.session.instance_id.0,
5417 address.session.generation.0,
5418 ))
5419 .map_err(|_| {
5420 failure(
5421 NodeFailureCode::BackendOperationFailed,
5422 "session environment materialization identity failed",
5423 )
5424 })?;
5425 let owner = MaterializationOwner::Session {
5426 incarnation_id: self.incarnation_id,
5427 instance_id: address.session.instance_id,
5428 generation: address.session.generation,
5429 };
5430 let mut ownership = materializer
5431 .begin(
5432 id.clone(),
5433 environment_profile.cloned(),
5434 bundle.cloned(),
5435 context.cloned(),
5436 owner,
5437 managed_lease_id,
5438 &profile,
5439 unix_time_ms(),
5440 )
5441 .map_err(|_| {
5442 failure(
5443 materialization_failure_code,
5444 "session materialization preparation failed",
5445 )
5446 })?;
5447 {
5448 let _transaction = self
5449 .state_transaction
5450 .lock()
5451 .unwrap_or_else(|poisoned| poisoned.into_inner());
5452 let duplicate = self
5453 .materializations
5454 .lock()
5455 .unwrap_or_else(|poisoned| poisoned.into_inner())
5456 .insert(id.clone(), ownership.clone());
5457 if duplicate.is_some() {
5458 return Err(failure(
5459 NodeFailureCode::BackendOperationFailed,
5460 "session environment materialization identity conflict",
5461 ));
5462 }
5463 if let Err(error) = self.persist_state_locked() {
5464 self.materializations
5465 .lock()
5466 .unwrap_or_else(|poisoned| poisoned.into_inner())
5467 .remove(&id);
5468 return Err(persistence_failure(error));
5469 }
5470 }
5471 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5472 Ok(environment) => environment,
5473 Err(_) => {
5474 let transaction = self
5475 .state_transaction
5476 .lock()
5477 .unwrap_or_else(|poisoned| poisoned.into_inner());
5478 self.materializations
5479 .lock()
5480 .unwrap_or_else(|poisoned| poisoned.into_inner())
5481 .insert(id.clone(), ownership.clone());
5482 if self.persist_state_locked().is_err() {
5483 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5484 }
5485 drop(transaction);
5486 if ownership.state() == MaterializationState::CleanupRequired {
5487 let _ = self.cleanup_materialization(&id);
5488 }
5489 return Err(failure(
5490 materialization_failure_code,
5491 "session materialization failed",
5492 ));
5493 }
5494 };
5495 {
5496 let _transaction = self
5497 .state_transaction
5498 .lock()
5499 .unwrap_or_else(|poisoned| poisoned.into_inner());
5500 self.materializations
5501 .lock()
5502 .unwrap_or_else(|poisoned| poisoned.into_inner())
5503 .insert(id.clone(), ownership.clone());
5504 if let Err(error) = self.persist_state_locked() {
5505 drop(_transaction);
5506 let _ = self.cleanup_materialization(&id);
5507 return Err(persistence_failure(error));
5508 }
5509 }
5510 let guard = SessionMaterializationGuard {
5511 shared: self,
5512 id: Some(id),
5513 provider_home: Some(ownership.provider_home().to_path_buf()),
5514 };
5515 let bundle_arguments = match bundle {
5516 Some(receipt) => {
5517 let catalog = self.bundle_catalog.read()
5518 .unwrap_or_else(|poisoned| poisoned.into_inner());
5519 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5520 NodeFailureCode::UnknownBundle,
5521 "session bundle is unavailable on this node",
5522 ))?;
5523 if installed.receipt() != *receipt {
5524 return Err(failure(
5525 NodeFailureCode::BundleBindingMismatch,
5526 "session bundle revision or digest changed",
5527 ));
5528 }
5529 let layout = bundle_layout.ok_or_else(|| failure(
5530 NodeFailureCode::BundleBindingMismatch,
5531 "materialized bundle provider layout is unavailable",
5532 ))?;
5533 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5534 failure(
5535 NodeFailureCode::BundleMaterializationFailed,
5536 "session bundle byte revalidation failed",
5537 )
5538 })?;
5539 Some(bundle_launch_arguments(
5540 layout,
5541 ownership.bundle_root(),
5542 ownership.provider_home(),
5543 ).map_err(|_| failure(
5544 NodeFailureCode::BundleBindingMismatch,
5545 "session bundle launch binding is invalid",
5546 ))?)
5547 }
5548 None => None,
5549 };
5550 if let Some(receipt) = context {
5551 materializer
5552 .revalidate_context(&ownership, receipt)
5553 .map_err(|_| failure(
5554 NodeFailureCode::ContextPackMaterializationFailed,
5555 "session context pack byte revalidation failed",
5556 ))?;
5557 }
5558 let transport = match mode {
5559 SessionMode::Pty => TransportKind::Pty,
5560 SessionMode::Inline => TransportKind::Pipe,
5561 SessionMode::Acp => TransportKind::Acp,
5562 };
5563 let overlay = if let Some(extra_args) = bundle_arguments {
5564 Some(PreparedNativeLaunchOverlay::Instance(
5565 NativeInstanceLaunchOverlay::new(
5566 provider.clone(),
5567 transport,
5568 if has_environment_overlay {
5569 environment
5570 } else {
5571 Vec::new()
5572 },
5573 extra_args,
5574 )
5575 .map_err(|_| {
5576 failure(
5577 NodeFailureCode::BundleBindingMismatch,
5578 "session bundle launch overlay is invalid",
5579 )
5580 })?,
5581 ))
5582 } else if has_environment_overlay {
5583 let context_only = environment_profile.is_none()
5584 && bundle.is_none()
5585 && context.is_some();
5586 let overlay = if context_only {
5587 NativeLaunchEnvironmentOverlay::new_context_root(
5588 provider.clone(),
5589 transport,
5590 environment,
5591 )
5592 .map_err(|_| {
5593 failure(
5594 NodeFailureCode::ContextPackMaterializationFailed,
5595 "context-only launch overlay is invalid",
5596 )
5597 })?
5598 } else {
5599 NativeLaunchEnvironmentOverlay::new(
5600 provider.clone(),
5601 transport,
5602 environment,
5603 )
5604 .map_err(|_| {
5605 failure(
5606 NodeFailureCode::BackendOperationFailed,
5607 "session environment overlay is invalid",
5608 )
5609 })?
5610 };
5611 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5612 } else {
5613 None
5614 };
5615 Ok(Some((overlay, guard)))
5616 }
5617
5618 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5619 let Some(mut ownership) = self
5620 .materializations
5621 .lock()
5622 .unwrap_or_else(|poisoned| poisoned.into_inner())
5623 .get(id)
5624 .cloned()
5625 else {
5626 return Ok(());
5627 };
5628 if ownership.state() != MaterializationState::CleanupRequired {
5629 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5630 failure(
5631 NodeFailureCode::BackendOperationFailed,
5632 "session environment cleanup state is invalid",
5633 )
5634 })?;
5635 }
5636 {
5637 let _transaction = self
5638 .state_transaction
5639 .lock()
5640 .unwrap_or_else(|poisoned| poisoned.into_inner());
5641 let previous = self
5642 .materializations
5643 .lock()
5644 .unwrap_or_else(|poisoned| poisoned.into_inner())
5645 .insert(id.clone(), ownership.clone());
5646 if let Err(error) = self.persist_state_locked() {
5647 let mut materializations = self
5648 .materializations
5649 .lock()
5650 .unwrap_or_else(|poisoned| poisoned.into_inner());
5651 if let Some(previous) = previous {
5652 materializations.insert(id.clone(), previous);
5653 }
5654 return Err(persistence_failure(error));
5655 }
5656 }
5657 let cleanup = self
5658 .session_environment_materializer
5659 .as_ref()
5660 .ok_or(())
5661 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5662 if cleanup.is_err() {
5663 let _ = ownership.mark_recovery_required(unix_time_ms());
5664 self.materializations
5665 .lock()
5666 .unwrap_or_else(|poisoned| poisoned.into_inner())
5667 .insert(id.clone(), ownership);
5668 if self.persist_state().is_err() {
5669 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5670 }
5671 return Err(failure(
5672 NodeFailureCode::BackendOperationFailed,
5673 "session environment cleanup requires recovery",
5674 ));
5675 }
5676 let _transaction = self
5677 .state_transaction
5678 .lock()
5679 .unwrap_or_else(|poisoned| poisoned.into_inner());
5680 self.materializations
5681 .lock()
5682 .unwrap_or_else(|poisoned| poisoned.into_inner())
5683 .remove(id);
5684 if let Err(error) = self.persist_state_locked() {
5685 self.materializations
5686 .lock()
5687 .unwrap_or_else(|poisoned| poisoned.into_inner())
5688 .insert(id.clone(), ownership);
5689 return Err(persistence_failure(error));
5690 }
5691 Ok(())
5692 }
5693
5694 fn resolve_record_materialization(
5695 &self,
5696 record_id: &SessionRecordId,
5697 provider: &AgentId,
5698 mode: SessionMode,
5699 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5700 bundle: Option<&ResolvedBundleReceipt>,
5701 context: Option<&ResolvedContextPackReceipt>,
5702 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5703 let ownership = {
5704 let materializations = self
5705 .materializations
5706 .lock()
5707 .unwrap_or_else(|poisoned| poisoned.into_inner());
5708 let mut matches = materializations.values().filter(|record| {
5709 record.owner()
5710 == &MaterializationOwner::Record {
5711 record_id: record_id.clone(),
5712 }
5713 });
5714 let ownership = matches.next().cloned();
5715 if matches.next().is_some() {
5716 return Err(failure(
5717 NodeFailureCode::BackendOperationFailed,
5718 "managed session has conflicting environment ownership",
5719 ));
5720 }
5721 ownership
5722 };
5723 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5724 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5725 failure(
5726 NodeFailureCode::ContextPackMaterializationFailed,
5727 "session context materializer is unavailable",
5728 )
5729 })?;
5730 let pack = materializer
5731 .revalidate_context(ownership, receipt)
5732 .map_err(|_| {
5733 self.mark_materialization_recovery_required(ownership.id());
5734 failure(
5735 NodeFailureCode::ContextPackMaterializationFailed,
5736 "managed session context pack revalidation failed",
5737 )
5738 })?;
5739 self.context_catalog
5740 .write()
5741 .unwrap_or_else(|poisoned| poisoned.into_inner())
5742 .insert(pack)
5743 .map_err(|_| failure(
5744 NodeFailureCode::ContextPackMaterializationFailed,
5745 "managed session context pack catalog restore failed",
5746 ))?;
5747 }
5748 let materialization = self.materialization_profile(
5749 provider,
5750 mode,
5751 environment_profile,
5752 bundle,
5753 context,
5754 )?;
5755 match (ownership, materialization) {
5756 (None, None) => Ok(None),
5757 (None, Some(_)) | (Some(_), None) => Err(failure(
5758 NodeFailureCode::BackendOperationFailed,
5759 "managed session materialization is unavailable",
5760 )),
5761 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5762 if ownership.environment_profile() != environment_profile
5763 || ownership.bundle() != bundle
5764 || ownership.context() != context
5765 || ownership.state() != MaterializationState::Ready
5766 {
5767 self.mark_materialization_recovery_required(ownership.id());
5768 return Err(failure(
5769 NodeFailureCode::BackendOperationFailed,
5770 "managed session environment ownership requires recovery",
5771 ));
5772 }
5773 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5774 failure(
5775 NodeFailureCode::BackendOperationFailed,
5776 "session environment materializer is unavailable",
5777 )
5778 })?;
5779 if materializer.revalidate(&ownership).is_err() {
5780 self.mark_materialization_recovery_required(ownership.id());
5781 return Err(failure(
5782 NodeFailureCode::BackendOperationFailed,
5783 "managed session environment revalidation failed",
5784 ));
5785 }
5786 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5787 let catalog = self.bundle_catalog.read()
5788 .unwrap_or_else(|poisoned| poisoned.into_inner());
5789 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5790 return Err(failure(
5791 NodeFailureCode::UnknownBundle,
5792 "managed session bundle is unavailable",
5793 ));
5794 };
5795 let layout = bundle_layout.ok_or_else(|| failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "record bundle provider layout is unavailable",
5798 ))?;
5799 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5800 drop(catalog);
5801 self.mark_materialization_recovery_required(ownership.id());
5802 return Err(failure(
5803 NodeFailureCode::BundleMaterializationFailed,
5804 "managed session bundle revalidation failed",
5805 ));
5806 }
5807 Some(bundle_launch_arguments(
5808 layout,
5809 ownership.bundle_root(),
5810 ownership.provider_home(),
5811 ).map_err(|_| failure(
5812 NodeFailureCode::BundleBindingMismatch,
5813 "managed session bundle launch binding is invalid",
5814 ))?)
5815 } else {
5816 None
5817 };
5818 if !has_environment_materialization {
5819 let overlay = bundle_arguments.map(|extra_args| {
5820 NativeInstanceLaunchOverlay::new(
5821 provider.clone(),
5822 TransportKind::Pty,
5823 Vec::new(),
5824 extra_args,
5825 )
5826 .map(PreparedNativeLaunchOverlay::Instance)
5827 .map_err(|_| failure(
5828 NodeFailureCode::BundleBindingMismatch,
5829 "managed session bundle launch overlay is invalid",
5830 ))
5831 }).transpose()?;
5832 return Ok(Some((ownership.id().clone(), overlay)));
5833 }
5834 let environment = match materializer.resolve_environment(&ownership, &profile) {
5835 Ok(environment) => environment,
5836 Err(
5837 SessionEnvironmentMaterializeError::SecretUnavailable
5838 | SessionEnvironmentMaterializeError::SecretDenied
5839 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5840 ) => {
5841 return Err(failure(
5842 NodeFailureCode::BackendOperationFailed,
5843 "managed session environment resolution failed",
5844 ));
5845 }
5846 Err(
5847 SessionEnvironmentMaterializeError::InvalidRoot
5848 | SessionEnvironmentMaterializeError::OwnershipMismatch
5849 | SessionEnvironmentMaterializeError::Filesystem(_)
5850 | SessionEnvironmentMaterializeError::Record(_),
5851 ) => {
5852 self.mark_materialization_recovery_required(ownership.id());
5853 return Err(failure(
5854 NodeFailureCode::BackendOperationFailed,
5855 "managed session environment resolution failed",
5856 ));
5857 }
5858 };
5859 let transport = match mode {
5860 SessionMode::Pty => TransportKind::Pty,
5861 SessionMode::Inline => TransportKind::Pipe,
5862 SessionMode::Acp => TransportKind::Acp,
5863 };
5864 let overlay = match bundle_arguments {
5865 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5866 NativeInstanceLaunchOverlay::new(
5867 provider.clone(),
5868 transport,
5869 environment,
5870 extra_args,
5871 )
5872 .map_err(|_| failure(
5873 NodeFailureCode::BundleBindingMismatch,
5874 "managed session bundle launch overlay is invalid",
5875 ))?,
5876 ),
5877 None => PreparedNativeLaunchOverlay::Environment(
5878 NativeLaunchEnvironmentOverlay::new(
5879 provider.clone(),
5880 transport,
5881 environment,
5882 )
5883 .map_err(|_| {
5884 failure(
5885 NodeFailureCode::BackendOperationFailed,
5886 "managed session environment overlay is invalid",
5887 )
5888 })?,
5889 ),
5890 };
5891 Ok(Some((ownership.id().clone(), Some(overlay))))
5892 }
5893 }
5894 }
5895
5896 fn destroy_record_materialization(
5897 &self,
5898 record_id: &SessionRecordId,
5899 ) -> Result<bool, NodeFailure> {
5900 let ids = self
5901 .materializations
5902 .lock()
5903 .unwrap_or_else(|poisoned| poisoned.into_inner())
5904 .values()
5905 .filter_map(|record| {
5906 (record.owner()
5907 == &MaterializationOwner::Record {
5908 record_id: record_id.clone(),
5909 })
5910 .then(|| record.id().clone())
5911 })
5912 .collect::<Vec<_>>();
5913 if ids.len() > 1 {
5914 return Err(failure(
5915 NodeFailureCode::BackendOperationFailed,
5916 "managed session has conflicting environment ownership",
5917 ));
5918 }
5919 let Some(id) = ids.first().cloned() else {
5920 return Ok(false);
5921 };
5922 let (terminal_record, mut cleanup_ownership) = {
5923 let _transaction = self
5924 .state_transaction
5925 .lock()
5926 .unwrap_or_else(|poisoned| poisoned.into_inner());
5927 let original_record = self.record(record_id)?;
5928 let mut terminal_record = original_record.clone();
5929 terminal_record.provider_session = None;
5930 terminal_record.active_session = None;
5931 terminal_record.state = ManagedSessionState::Unavailable;
5932 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5933 terminal_record.updated_at_unix_ms = unix_time_ms();
5934 let original_ownership = self
5935 .materializations
5936 .lock()
5937 .unwrap_or_else(|poisoned| poisoned.into_inner())
5938 .get(&id)
5939 .cloned()
5940 .ok_or_else(|| {
5941 failure(
5942 NodeFailureCode::BackendOperationFailed,
5943 "managed session environment ownership disappeared during cleanup",
5944 )
5945 })?;
5946 let mut cleanup_ownership = original_ownership.clone();
5947 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5948 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5949 failure(
5950 NodeFailureCode::BackendOperationFailed,
5951 "managed session environment cleanup state is invalid",
5952 )
5953 })?;
5954 }
5955 self.session_records
5956 .lock()
5957 .unwrap_or_else(|poisoned| poisoned.into_inner())
5958 .records
5959 .insert(record_id.clone(), terminal_record.clone());
5960 self.materializations
5961 .lock()
5962 .unwrap_or_else(|poisoned| poisoned.into_inner())
5963 .insert(id.clone(), cleanup_ownership.clone());
5964 if let Err(error) = self.persist_state_locked() {
5965 self.session_records
5966 .lock()
5967 .unwrap_or_else(|poisoned| poisoned.into_inner())
5968 .records
5969 .insert(record_id.clone(), original_record);
5970 self.materializations
5971 .lock()
5972 .unwrap_or_else(|poisoned| poisoned.into_inner())
5973 .insert(id.clone(), original_ownership);
5974 return Err(persistence_failure(error));
5975 }
5976 (terminal_record, cleanup_ownership)
5977 };
5978 let cleanup = self
5979 .session_environment_materializer
5980 .as_ref()
5981 .ok_or(())
5982 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5983 if cleanup.is_err() {
5984 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5985 self.materializations
5986 .lock()
5987 .unwrap_or_else(|poisoned| poisoned.into_inner())
5988 .insert(id.clone(), cleanup_ownership);
5989 if self.persist_state().is_err() {
5990 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5991 }
5992 return Err(failure(
5993 NodeFailureCode::BackendOperationFailed,
5994 "managed session environment cleanup requires recovery",
5995 ));
5996 }
5997 let _transaction = self
5998 .state_transaction
5999 .lock()
6000 .unwrap_or_else(|poisoned| poisoned.into_inner());
6001 self.remove_record_memory(record_id);
6002 self.materializations
6003 .lock()
6004 .unwrap_or_else(|poisoned| poisoned.into_inner())
6005 .remove(&id);
6006 if let Err(error) = self.persist_state_locked() {
6007 self.session_records
6008 .lock()
6009 .unwrap_or_else(|poisoned| poisoned.into_inner())
6010 .records
6011 .insert(record_id.clone(), terminal_record);
6012 self.materializations
6013 .lock()
6014 .unwrap_or_else(|poisoned| poisoned.into_inner())
6015 .insert(id, cleanup_ownership);
6016 return Err(persistence_failure(error));
6017 }
6018 Ok(true)
6019 }
6020
6021 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6022 let changed = {
6023 let mut materializations = self
6024 .materializations
6025 .lock()
6026 .unwrap_or_else(|poisoned| poisoned.into_inner());
6027 let Some(ownership) = materializations.get_mut(id) else {
6028 return;
6029 };
6030 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6031 };
6032 if changed && self.persist_state().is_err() {
6033 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6034 }
6035 }
6036
6037 fn reconcile_materializations(&self) {
6038 let records = self
6039 .materializations
6040 .lock()
6041 .unwrap_or_else(|poisoned| poisoned.into_inner())
6042 .values()
6043 .cloned()
6044 .collect::<Vec<_>>();
6045 for mut ownership in records {
6046 let result = match (ownership.state(), ownership.owner()) {
6047 (MaterializationState::Preparing, _)
6048 | (MaterializationState::CleanupRequired, _) => {
6049 self.cleanup_materialization(ownership.id())
6050 }
6051 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6052 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6053 self.materializations
6054 .lock()
6055 .unwrap_or_else(|poisoned| poisoned.into_inner())
6056 .insert(ownership.id().clone(), ownership.clone());
6057 self.persist_state().map_err(persistence_failure)
6058 } else {
6059 Err(failure(
6060 NodeFailureCode::BackendOperationFailed,
6061 "session environment reconciliation failed",
6062 ))
6063 }
6064 }
6065 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6066 .session_environment_materializer
6067 .as_ref()
6068 .ok_or_else(|| {
6069 failure(
6070 NodeFailureCode::BackendOperationFailed,
6071 "session environment materializer is unavailable",
6072 )
6073 })
6074 .and_then(|materializer| {
6075 materializer.revalidate(&ownership).map_err(|_| failure(
6076 NodeFailureCode::BackendOperationFailed,
6077 "session materialization revalidation failed",
6078 ))?;
6079 let record = self.record(record_id)?;
6080 if record.environment_profile.as_ref() != ownership.environment_profile()
6081 || record.bundle.as_ref() != ownership.bundle()
6082 || record.context.as_ref() != ownership.context()
6083 {
6084 return Err(failure(
6085 NodeFailureCode::BackendOperationFailed,
6086 "session materialization ownership changed after restart",
6087 ));
6088 }
6089 if let Some(receipt) = ownership.bundle() {
6090 let catalog = self.bundle_catalog.read()
6091 .unwrap_or_else(|poisoned| poisoned.into_inner());
6092 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6093 NodeFailureCode::UnknownBundle,
6094 "session bundle is unavailable after restart",
6095 ))?;
6096 if bundle.receipt() != *receipt {
6097 return Err(failure(
6098 NodeFailureCode::BundleBindingMismatch,
6099 "session bundle changed after restart",
6100 ));
6101 }
6102 let layout = self.bundle_layout(
6103 &record.provider,
6104 record.mode,
6105 record.environment_profile.as_ref(),
6106 bundle,
6107 )?;
6108 materializer
6109 .revalidate_bundle(&ownership, bundle, layout)
6110 .map_err(|_| failure(
6111 NodeFailureCode::BundleMaterializationFailed,
6112 "session bundle revalidation failed",
6113 ))?;
6114 }
6115 if let Some(receipt) = ownership.context() {
6116 let pack = materializer
6117 .revalidate_context(&ownership, receipt)
6118 .map_err(|_| {
6119 failure(
6120 NodeFailureCode::ContextPackMaterializationFailed,
6121 "session context revalidation failed",
6122 )
6123 })?;
6124 self.context_catalog
6125 .write()
6126 .unwrap_or_else(|poisoned| poisoned.into_inner())
6127 .insert(pack)
6128 .map_err(|_| {
6129 failure(
6130 NodeFailureCode::ContextPackMaterializationFailed,
6131 "session context catalog restore failed",
6132 )
6133 })?;
6134 }
6135 Ok(())
6136 }),
6137 (MaterializationState::RecoveryRequired, _) => Ok(()),
6138 };
6139 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6140 let _ = ownership.mark_recovery_required(unix_time_ms());
6141 self.materializations
6142 .lock()
6143 .unwrap_or_else(|poisoned| poisoned.into_inner())
6144 .insert(ownership.id().clone(), ownership);
6145 if self.persist_state().is_err() {
6146 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6147 }
6148 }
6149 }
6150 let materialized_profiles = self
6151 .environment_materialization_profiles
6152 .read()
6153 .unwrap_or_else(|poisoned| poisoned.into_inner())
6154 .keys()
6155 .cloned()
6156 .collect::<Vec<_>>();
6157 let ownership = self
6158 .materializations
6159 .lock()
6160 .unwrap_or_else(|poisoned| poisoned.into_inner())
6161 .values()
6162 .cloned()
6163 .collect::<Vec<_>>();
6164 let changed = {
6165 let mut session_records = self
6166 .session_records
6167 .lock()
6168 .unwrap_or_else(|poisoned| poisoned.into_inner());
6169 let mut changed = false;
6170 for record in session_records.records.values_mut() {
6171 let environment_profile = record.environment_profile.as_ref();
6172 if environment_profile.is_none()
6173 && record.bundle.is_none()
6174 && record.context.is_none()
6175 {
6176 continue;
6177 }
6178 if record.bundle.is_none()
6179 && environment_profile.is_some_and(|profile| {
6180 !materialized_profiles.contains(&profile.profile_id)
6181 })
6182 {
6183 continue;
6184 }
6185 let has_exact_ownership = ownership.iter().any(|candidate| {
6186 candidate.state() == MaterializationState::Ready
6187 && candidate.environment_profile() == environment_profile
6188 && candidate.bundle() == record.bundle.as_ref()
6189 && candidate.context() == record.context.as_ref()
6190 && candidate.owner()
6191 == &MaterializationOwner::Record {
6192 record_id: record.record_id.clone(),
6193 }
6194 });
6195 if !has_exact_ownership {
6196 record.provider_session = None;
6197 record.active_session = None;
6198 record.state = ManagedSessionState::Unavailable;
6199 record.last_error = Some(if record.bundle.is_some() {
6200 "bundle-unavailable".to_owned()
6201 } else if record.context.is_some() {
6202 "context-unavailable".to_owned()
6203 } else {
6204 "environment-profile-unavailable".to_owned()
6205 });
6206 record.updated_at_unix_ms = unix_time_ms();
6207 changed = true;
6208 }
6209 }
6210 changed
6211 };
6212 if changed && self.persist_state().is_err() {
6213 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6214 }
6215 }
6216
6217 fn replay_spawn_spec(
6218 &self,
6219 spec: &SpawnSpec,
6220 now: Instant,
6221 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6222 let mut cache = self
6223 .spawn_idempotency
6224 .lock()
6225 .unwrap_or_else(|poisoned| poisoned.into_inner());
6226 cache.entries.retain(|_, entry| entry.expires_at > now);
6227 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6228 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6229 return Err(failure(
6230 NodeFailureCode::SpawnIdempotencyConflict,
6231 "spawn idempotency key was reused across spawn kinds",
6232 ));
6233 };
6234 if existing != spec {
6235 return Err(failure(
6236 NodeFailureCode::SpawnIdempotencyConflict,
6237 "spawn idempotency key was reused with a different specification",
6238 ));
6239 }
6240 return Ok(Some(result.clone()));
6241 }
6242 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6243 return Err(failure(
6244 NodeFailureCode::SpawnIdempotencyCapacity,
6245 "spawn idempotency cache reached its bounded capacity",
6246 ));
6247 }
6248 Ok(None)
6249 }
6250
6251 fn remember_spawn_spec(
6252 &self,
6253 spec: SpawnSpec,
6254 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6255 accepted_at: Instant,
6256 ) {
6257 self
6258 .spawn_idempotency
6259 .lock()
6260 .unwrap_or_else(|poisoned| poisoned.into_inner())
6261 .entries
6262 .insert(
6263 spec.idempotency_key.clone(),
6264 SpawnIdempotencyEntry {
6265 value: SpawnIdempotencyValue::Standard { spec, result },
6266 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6267 },
6268 );
6269 }
6270
6271 fn claim_harness_mcp_spawn(
6272 &self,
6273 _reservation_id: &HarnessMcpReservationId,
6274 _activation_digest: &HarnessMcpActivationDigest,
6275 spec: &SpawnSpec,
6276 now: Instant,
6277 ) -> Result<(), NodeFailure> {
6278 let mut cache = self.spawn_idempotency.lock()
6279 .unwrap_or_else(|poisoned| poisoned.into_inner());
6280 cache.entries.retain(|_, entry| entry.expires_at > now);
6281 if cache.entries.contains_key(&spec.idempotency_key) {
6282 return Err(failure(
6283 NodeFailureCode::SpawnIdempotencyConflict,
6284 "spawn idempotency key was reused across spawn kinds",
6285 ));
6286 }
6287 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6288 return Err(failure(
6289 NodeFailureCode::SpawnIdempotencyCapacity,
6290 "spawn idempotency cache reached its bounded capacity",
6291 ));
6292 }
6293 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6294 value: SpawnIdempotencyValue::HarnessMcp,
6295 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6296 });
6297 Ok(())
6298 }
6299
6300 fn replay_managed_spawn(
6301 &self,
6302 request: &ManagedWorktreeSpawnRequest,
6303 now: Instant,
6304 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6305 let mut cache = self.spawn_idempotency.lock()
6306 .unwrap_or_else(|poisoned| poisoned.into_inner());
6307 cache.entries.retain(|_, entry| entry.expires_at > now);
6308 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6309 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6310 return Err(failure(
6311 NodeFailureCode::SpawnIdempotencyConflict,
6312 "spawn idempotency key was reused across spawn kinds",
6313 ));
6314 };
6315 if existing != request {
6316 return Err(failure(
6317 NodeFailureCode::SpawnIdempotencyConflict,
6318 "spawn idempotency key was reused with a different managed specification",
6319 ));
6320 }
6321 return Ok(Some(result.clone()));
6322 }
6323 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6324 return Err(failure(
6325 NodeFailureCode::SpawnIdempotencyCapacity,
6326 "spawn idempotency cache reached its bounded capacity",
6327 ));
6328 }
6329 Ok(None)
6330 }
6331
6332 fn remember_managed_spawn(
6333 &self,
6334 request: ManagedWorktreeSpawnRequest,
6335 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6336 accepted_at: Instant,
6337 ) {
6338 self.spawn_idempotency.lock()
6339 .unwrap_or_else(|poisoned| poisoned.into_inner())
6340 .entries
6341 .insert(
6342 request.spawn_spec.idempotency_key.clone(),
6343 SpawnIdempotencyEntry {
6344 value: SpawnIdempotencyValue::Managed { request, result },
6345 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6346 },
6347 );
6348 }
6349
6350 fn remember_managed_spawn_attempt(
6351 &self,
6352 durable_v2_key: Option<&SpawnIdempotencyKey>,
6353 request: ManagedWorktreeSpawnRequest,
6354 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6355 accepted_at: Instant,
6356 ) {
6357 if durable_v2_key.is_none() {
6358 self.remember_managed_spawn(request, result, accepted_at);
6359 }
6360 }
6361
6362 fn acquire_durable_managed_spawn_v2(
6363 &self,
6364 request: &ManagedWorktreeSpawnRequestV2,
6365 request_digest: String,
6366 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6367 if self.state_path.is_none() {
6368 return Err(failure(
6369 NodeFailureCode::BackendOperationFailed,
6370 "managed worktree V2 requires durable node state",
6371 ));
6372 }
6373 let _transaction = self.state_transaction
6374 .lock()
6375 .unwrap_or_else(|poisoned| poisoned.into_inner());
6376 let key = request.spawn_spec.idempotency_key.clone();
6377 let mut previous = None;
6378 {
6379 let mut replays = self.managed_spawn_replays
6380 .lock()
6381 .unwrap_or_else(|poisoned| poisoned.into_inner());
6382 if let Some(record) = replays.get_mut(&key) {
6383 if record.request_digest != request_digest {
6384 return Err(failure(
6385 NodeFailureCode::SpawnIdempotencyConflict,
6386 "managed worktree V2 idempotency key was reused with a different request",
6387 ));
6388 }
6389 match &record.state {
6390 ManagedWorktreeSpawnReplayStateV10::Pending {
6391 owner_incarnation_id,
6392 lease_id: None,
6393 } if owner_incarnation_id != &self.incarnation_id => {
6394 previous = Some(record.clone());
6395 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6396 owner_incarnation_id: self.incarnation_id,
6397 lease_id: None,
6398 };
6399 record.updated_at_unix_ms = unix_time_ms();
6400 }
6401 ManagedWorktreeSpawnReplayStateV10::Pending {
6402 lease_id: None,
6403 ..
6404 } => {
6405 return Err(failure(
6406 NodeFailureCode::BackendBusy,
6407 "managed worktree V2 reservation is already active",
6408 ));
6409 }
6410 ManagedWorktreeSpawnReplayStateV10::Pending {
6411 lease_id: Some(_),
6412 ..
6413 } => {
6414 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6415 }
6416 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6417 return Ok(DurableManagedSpawnReplayDecision::Committed(
6418 receipt.clone(),
6419 ));
6420 }
6421 }
6422 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6423 return Err(failure(
6424 NodeFailureCode::SpawnIdempotencyCapacity,
6425 "durable managed worktree replay ledger reached its bounded capacity",
6426 ));
6427 } else {
6428 let now = unix_time_ms();
6429 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6430 idempotency_key: key.clone(),
6431 request_digest,
6432 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6433 profile_id: request.worktree_profile_id.clone(),
6434 expected_profile_revision: request.expected_profile_revision.clone(),
6435 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6436 owner_incarnation_id: self.incarnation_id,
6437 lease_id: None,
6438 },
6439 created_at_unix_ms: now,
6440 updated_at_unix_ms: now,
6441 });
6442 }
6443 }
6444 if let Err(error) = self.persist_state_locked() {
6445 let mut replays = self.managed_spawn_replays
6446 .lock()
6447 .unwrap_or_else(|poisoned| poisoned.into_inner());
6448 match previous {
6449 Some(previous) => {
6450 replays.insert(key, previous);
6451 }
6452 None => {
6453 replays.remove(&key);
6454 }
6455 }
6456 return Err(persistence_failure(error));
6457 }
6458 Ok(DurableManagedSpawnReplayDecision::Reserved)
6459 }
6460
6461 fn link_durable_managed_spawn_v2_lease_locked(
6462 &self,
6463 key: &SpawnIdempotencyKey,
6464 lease_id: &ManagedWorktreeLeaseId,
6465 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6466 let mut replays = self.managed_spawn_replays
6467 .lock()
6468 .unwrap_or_else(|poisoned| poisoned.into_inner());
6469 let record = replays.get_mut(key).ok_or_else(|| {
6470 failure(
6471 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6472 "managed worktree V2 reservation disappeared before allocation",
6473 )
6474 })?;
6475 let previous = record.clone();
6476 match &mut record.state {
6477 ManagedWorktreeSpawnReplayStateV10::Pending {
6478 lease_id: current,
6479 ..
6480 }
6481 if current.is_none() =>
6482 {
6483 *current = Some(lease_id.clone());
6484 record.updated_at_unix_ms = unix_time_ms();
6485 Ok(previous)
6486 }
6487 _ => Err(failure(
6488 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6489 "managed worktree V2 reservation is not allocation-ready",
6490 )),
6491 }
6492 }
6493
6494 fn restore_durable_managed_spawn_v2_locked(
6495 &self,
6496 key: &SpawnIdempotencyKey,
6497 previous: ManagedWorktreeSpawnReplayRecordV10,
6498 ) {
6499 self.managed_spawn_replays
6500 .lock()
6501 .unwrap_or_else(|poisoned| poisoned.into_inner())
6502 .insert(key.clone(), previous);
6503 }
6504
6505 fn commit_durable_managed_spawn_v2(
6506 &self,
6507 request: &ManagedWorktreeSpawnRequestV2,
6508 receipt: ManagedWorktreeSpawnReceipt,
6509 ) -> Result<(), NodeFailure> {
6510 let _transaction = self.state_transaction
6511 .lock()
6512 .unwrap_or_else(|poisoned| poisoned.into_inner());
6513 let key = &request.spawn_spec.idempotency_key;
6514 let previous = {
6515 let mut replays = self.managed_spawn_replays
6516 .lock()
6517 .unwrap_or_else(|poisoned| poisoned.into_inner());
6518 let record = replays.get_mut(key).ok_or_else(|| {
6519 failure(
6520 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6521 "managed worktree V2 reservation disappeared before commit",
6522 )
6523 })?;
6524 let previous = record.clone();
6525 match &record.state {
6526 ManagedWorktreeSpawnReplayStateV10::Pending {
6527 lease_id: Some(lease_id),
6528 ..
6529 } if lease_id == &receipt.lease.lease_id => {}
6530 _ => {
6531 return Err(failure(
6532 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6533 "managed worktree V2 reservation is not linked to the spawned lease",
6534 ));
6535 }
6536 }
6537 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6538 record.updated_at_unix_ms = unix_time_ms();
6539 previous
6540 };
6541 if let Err(error) = self.persist_state_locked() {
6542 self.restore_durable_managed_spawn_v2_locked(key, previous);
6543 return Err(persistence_failure(error));
6544 }
6545 Ok(())
6546 }
6547
6548 fn remove_unallocated_durable_managed_spawn_v2(
6549 &self,
6550 request: &ManagedWorktreeSpawnRequestV2,
6551 ) -> Result<(), NodeFailure> {
6552 let _transaction = self.state_transaction
6553 .lock()
6554 .unwrap_or_else(|poisoned| poisoned.into_inner());
6555 let key = &request.spawn_spec.idempotency_key;
6556 let removed = {
6557 let mut replays = self.managed_spawn_replays
6558 .lock()
6559 .unwrap_or_else(|poisoned| poisoned.into_inner());
6560 match replays.get(key).map(|record| &record.state) {
6561 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6562 lease_id: None,
6563 ..
6564 }) => {
6565 replays.remove(key)
6566 }
6567 _ => None,
6568 }
6569 };
6570 let Some(removed) = removed else {
6571 return Ok(());
6572 };
6573 if let Err(error) = self.persist_state_locked() {
6574 self.managed_spawn_replays
6575 .lock()
6576 .unwrap_or_else(|poisoned| poisoned.into_inner())
6577 .insert(key.clone(), removed);
6578 return Err(persistence_failure(error));
6579 }
6580 Ok(())
6581 }
6582
6583 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6584 if spec.target.node_id != self.node_id {
6585 return Err(failure(
6586 NodeFailureCode::SpawnTargetMismatch,
6587 "spawn target does not match this node",
6588 ));
6589 }
6590 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6591 failure(
6592 NodeFailureCode::UnknownSpawnProfile,
6593 "spawn profile is unavailable on this node",
6594 )
6595 })?;
6596 let resolved = spec.resolve(defaults).map_err(|error| match error {
6597 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6598 NodeFailureCode::SpawnProfileRevisionMismatch,
6599 "spawn profile revision does not match the loaded profile",
6600 ),
6601 _ => failure(
6602 NodeFailureCode::InvalidRequest,
6603 "spawn specification could not be resolved",
6604 ),
6605 })?;
6606 if resolved.target.worktree_id.is_some() {
6607 self.require_worktree_service(&resolved.target.workspace_id)?;
6608 }
6609 let environment_profile = self.resolve_environment_profile(&resolved)?;
6610 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611 self.resolve_context(&resolved)?;
6612 Ok(resolved)
6613 }
6614
6615 async fn resolve_spawn_workspace(
6616 &self,
6617 resolved: &ResolvedSpawnSpec,
6618 deadline: Instant,
6619 ) -> Result<WorkspaceId, NodeFailure> {
6620 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6621 return Ok(resolved.target.workspace_id.clone());
6622 };
6623 if self.managed_worktrees.lock()
6624 .unwrap_or_else(|poisoned| poisoned.into_inner())
6625 .lease_for_workspace(worktree_id)
6626 .is_some()
6627 {
6628 return Err(failure(
6629 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6630 "managed worktree targets may only be spawned through their lease request",
6631 ));
6632 }
6633 self.require_worktree_service(&resolved.target.workspace_id)?;
6634 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6635 let selected_root = self.workspace_root(worktree_id)?;
6636 let remaining = spawn_deadline_remaining(deadline)?;
6637 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6638 .await
6639 .map_err(|_| {
6640 failure(
6641 NodeFailureCode::SpawnDeadlineExceeded,
6642 "Git worktree selection exceeded the spawn deadline",
6643 )
6644 })?
6645 .map_err(git_worktree_failure)?;
6646 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6647 Ok(worktree_id.clone())
6648 }
6649
6650 async fn spawn_from_spec(
6651 &self,
6652 spec: SpawnSpec,
6653 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6654 self.spawn_from_spec_with_proxy(spec, None).await
6655 }
6656
6657 async fn spawn_from_spec_with_proxy(
6658 &self,
6659 spec: SpawnSpec,
6660 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6661 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6662 let accepted_at = Instant::now();
6663 if harness_mcp.is_none() {
6664 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6665 return replayed;
6666 }
6667 }
6668 let resolved = self.resolve_spawn_spec(&spec)?;
6669 let environment_profile = self.resolve_environment_profile(&resolved)?;
6670 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6671 let context = self.resolve_context(&resolved)?;
6672 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6673 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6674 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6675 let spawn_workspace_id = match spawn_workspace_id {
6676 Ok(workspace_id) => workspace_id,
6677 Err(error) => {
6678 let result = Err(error);
6679 if harness_mcp.is_none() {
6680 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6681 }
6682 return result;
6683 }
6684 };
6685 if self.managed_worktrees.lock()
6686 .unwrap_or_else(|poisoned| poisoned.into_inner())
6687 .lease_for_workspace(&spawn_workspace_id)
6688 .is_some()
6689 {
6690 let result = Err(failure(
6691 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6692 "managed worktree targets may only be spawned through their lease request",
6693 ));
6694 if harness_mcp.is_none() {
6695 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6696 }
6697 return result;
6698 }
6699 let result = self
6700 .spawn_session_with_deadline(
6701 spawn_workspace_id,
6702 resolved.provider.clone(),
6703 resolved.mode,
6704 resolved.terminal_size,
6705 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6706 environment_profile.clone(),
6707 bundle.clone(),
6708 context.clone(),
6709 None,
6710 None,
6711 SpawnRecordPolicy::Always,
6712 Some(deadline),
6713 &required_capabilities,
6714 harness_mcp,
6715 resolved.approval_level,
6716 resolved.network_allowlist.clone(),
6717 resolved.browser_profile_id.clone(),
6718 )
6719 .await
6720 .map(|(session, _runtime_policy)| {
6721 resolved.receipt_with_materialization(
6722 self.incarnation_id,
6723 session,
6724 environment_profile,
6725 bundle,
6726 context,
6727 )
6728 });
6729 if harness_mcp.is_none() {
6730 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6731 }
6732 result
6733 }
6734
6735 async fn spawn_from_spec_with_harness_mcp(
6736 &self,
6737 reservation_id: HarnessMcpReservationId,
6738 activation_digest: HarnessMcpActivationDigest,
6739 spec: SpawnSpec,
6740 deadline_unix_ms: u64,
6741 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6742 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6743 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6744 })?;
6745 if let Some(receipt) = registry
6746 .replay_spawn(&reservation_id, &activation_digest, &spec)
6747 .map_err(harness_mcp_failure)?
6748 {
6749 return Ok(receipt);
6750 }
6751 let resolved = self.resolve_spawn_spec(&spec)?;
6752 let prepared = registry
6753 .prepare_spawn(
6754 &reservation_id,
6755 &activation_digest,
6756 &spec,
6757 deadline_unix_ms,
6758 resolved.provider.clone(),
6759 )
6760 .map_err(harness_mcp_failure)?;
6761 self.claim_harness_mcp_spawn(
6762 &reservation_id,
6763 &activation_digest,
6764 &spec,
6765 Instant::now(),
6766 )?;
6767 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6768 let mut receipt = match spawn {
6769 Ok(receipt) => receipt,
6770 Err(error) => {
6771 let _ = registry.abort(&reservation_id, &activation_digest);
6772 return Err(error);
6773 }
6774 };
6775 let record_id = self
6776 .session_bindings
6777 .lock()
6778 .unwrap_or_else(|poisoned| poisoned.into_inner())
6779 .get(&receipt.session.session.instance_id)
6780 .and_then(|binding| binding.record_id.clone());
6781 let record_id = match record_id {
6782 Some(record_id) => record_id,
6783 None => {
6784 let rollback = self.remove_session(&receipt.session).await;
6785 let _ = registry.abort(&reservation_id, &activation_digest);
6786 if let Err(rollback_error) = rollback {
6787 return Err(rollback_error);
6788 }
6789 return Err(failure(
6790 NodeFailureCode::BindingMismatch,
6791 "harness MCP spawn did not retain its exact managed record",
6792 ));
6793 }
6794 };
6795 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6796 reservation_id: reservation_id.clone(),
6797 activation_digest: activation_digest.clone(),
6798 });
6799 if let Err(error) = registry.mark_spawned(
6800 &prepared,
6801 receipt.session.clone(),
6802 record_id,
6803 receipt.clone(),
6804 ) {
6805 let rollback = self.remove_session(&receipt.session).await;
6806 let _ = registry.abort(&reservation_id, &activation_digest);
6807 if let Err(rollback_error) = rollback {
6808 return Err(rollback_error);
6809 }
6810 return Err(harness_mcp_failure(error));
6811 }
6812 Ok(receipt)
6813 }
6814
6815 async fn spawn_managed_worktree(
6816 &self,
6817 request: ManagedWorktreeSpawnRequest,
6818 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6819 self.spawn_managed_worktree_inner(request, None).await
6820 }
6821
6822 async fn spawn_managed_worktree_inner(
6823 &self,
6824 request: ManagedWorktreeSpawnRequest,
6825 durable_v2_key: Option<&SpawnIdempotencyKey>,
6826 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6827 let accepted_at = Instant::now();
6828 if durable_v2_key.is_none() {
6829 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6830 return replayed;
6831 }
6832 }
6833 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6834 if request.spawn_spec.target.worktree_id.is_some() {
6835 let result = Err(failure(
6836 NodeFailureCode::InvalidRequest,
6837 "managed worktree spawn must not select a caller-provided worktree",
6838 ));
6839 self.remember_managed_spawn_attempt(
6840 durable_v2_key,
6841 request,
6842 result.clone(),
6843 accepted_at,
6844 );
6845 return result;
6846 }
6847 let environment_profile = self.resolve_environment_profile(&resolved)?;
6848 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6849 let context = self.resolve_context(&resolved)?;
6850 let source_workspace_id = resolved.target.workspace_id.clone();
6851 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6852 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6853 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6854 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6855 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6856 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6857 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6858 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6859 };
6860 let runtime_policy = timeout(
6861 spawn_deadline_remaining(deadline)?,
6862 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6863 ).await.map_err(|_| failure(
6864 NodeFailureCode::SpawnDeadlineExceeded,
6865 "provider admission exceeded the managed spawn deadline",
6866 ))??;
6867 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6868 return Err(failure(
6869 NodeFailureCode::UnsupportedSpawnCapability,
6870 "provider runtime does not admit a required spawn capability",
6871 ));
6872 }
6873 self.ensure_binding_capacity()?;
6874 let source_root = self.workspace_root(&source_workspace_id)?;
6875 profile.validate_for_workspace(&source_root)
6876 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6877 let base_timeout_ms = u64::try_from(
6878 spawn_deadline_remaining(deadline)?.as_millis(),
6879 ).unwrap_or(u64::MAX).max(1);
6880 let base_commit = resolve_base_commit_with_timeout(
6881 &source_root,
6882 profile.base(),
6883 base_timeout_ms,
6884 ).await.map_err(|error| {
6885 if Instant::now() >= deadline || error.message.contains("timed out") {
6886 failure(
6887 NodeFailureCode::SpawnDeadlineExceeded,
6888 "managed worktree base resolution exceeded the spawn deadline",
6889 )
6890 } else {
6891 managed_git_worktree_failure(error)
6892 }
6893 })?;
6894
6895 let lease = {
6896 let _transaction = self.state_transaction.lock()
6897 .unwrap_or_else(|poisoned| poisoned.into_inner());
6898 let previous = self.managed_worktrees.lock()
6899 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6900 let lease = self.managed_worktrees.lock()
6901 .unwrap_or_else(|poisoned| poisoned.into_inner())
6902 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6903 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6904 let previous_replay = match durable_v2_key
6905 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6906 .transpose()
6907 {
6908 Ok(previous) => previous,
6909 Err(error) => {
6910 *self.managed_worktrees.lock()
6911 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6912 return Err(error);
6913 }
6914 };
6915 if let Err(error) = self.persist_state_locked() {
6916 *self.managed_worktrees.lock()
6917 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6918 if let (Some(key), Some(previous_replay)) =
6919 (durable_v2_key, previous_replay)
6920 {
6921 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6922 }
6923 return Err(persistence_failure(error));
6924 }
6925 lease
6926 };
6927 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6928 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6929 self.managed_worktrees.lock()
6930 .unwrap_or_else(|poisoned| poisoned.into_inner())
6931 .remove_unmutated(&lease.lease_id);
6932 if self.persist_state().is_err() {
6933 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6934 }
6935 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6936 self.remember_managed_spawn_attempt(
6937 durable_v2_key,
6938 request,
6939 result.clone(),
6940 accepted_at,
6941 );
6942 return result;
6943 }
6944 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6945 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6946 Err(error) => {
6947 self.reconcile_failed_allocation(&lease, &source_root).await;
6948 let result = Err(error);
6949 self.remember_managed_spawn_attempt(
6950 durable_v2_key,
6951 request,
6952 result.clone(),
6953 accepted_at,
6954 );
6955 return result;
6956 }
6957 };
6958 let created = match create_git_worktree_with_timeout(
6959 &source_root,
6960 &lease.target_root,
6961 &lease.branch,
6962 Some(&base_commit),
6963 mutation_timeout_ms,
6964 ).await {
6965 Ok(created) => created,
6966 Err(error) => {
6967 self.reconcile_failed_allocation(&lease, &source_root).await;
6968 let result = Err(if Instant::now() >= deadline
6969 || error.message.contains("timed out")
6970 || error.message.contains("deadline")
6971 {
6972 failure(
6973 NodeFailureCode::SpawnDeadlineExceeded,
6974 "managed worktree creation exceeded the spawn deadline",
6975 )
6976 } else {
6977 managed_git_worktree_failure(error)
6978 });
6979 self.remember_managed_spawn_attempt(
6980 durable_v2_key,
6981 request,
6982 result.clone(),
6983 accepted_at,
6984 );
6985 return result;
6986 }
6987 };
6988 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6989 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6990 let result = Err(failure(
6991 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6992 "created worktree identity did not match its durable lease",
6993 ));
6994 self.remember_managed_spawn_attempt(
6995 durable_v2_key,
6996 request,
6997 result.clone(),
6998 accepted_at,
6999 );
7000 return result;
7001 }
7002 {
7003 let mut registry = self.managed_worktrees.lock()
7004 .unwrap_or_else(|poisoned| poisoned.into_inner());
7005 let current = registry.get_mut(&lease.lease_id)
7006 .expect("allocated managed worktree lease remains present");
7007 current.expected_head = Some(created.head.clone());
7008 current.state = ManagedWorktreeLeaseState::Ready;
7009 current.updated_at_unix_ms = unix_time_ms();
7010 }
7011 if let Err(error) = self.persist_state() {
7012 self.mark_managed_recovery_required(
7013 &lease.lease_id,
7014 ManagedWorktreeCleanupFailure::Backend,
7015 );
7016 let result = Err(persistence_failure(error));
7017 self.remember_managed_spawn_attempt(
7018 durable_v2_key,
7019 request,
7020 result.clone(),
7021 accepted_at,
7022 );
7023 return result;
7024 }
7025 let ready = self.managed_worktrees.lock()
7026 .unwrap_or_else(|poisoned| poisoned.into_inner())
7027 .get(&lease.lease_id)
7028 .expect("ready managed worktree lease remains present")
7029 .snapshot();
7030 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7031 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7032 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7033 let result = Err(error);
7034 self.remember_managed_spawn_attempt(
7035 durable_v2_key,
7036 request,
7037 result.clone(),
7038 accepted_at,
7039 );
7040 return result;
7041 }
7042 let spawn = self.spawn_session_with_deadline(
7043 lease.workspace_id.clone(),
7044 resolved.provider.clone(),
7045 resolved.mode,
7046 resolved.terminal_size,
7047 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7048 environment_profile.clone(),
7049 bundle.clone(),
7050 context.clone(),
7051 Some(lease.lease_id.clone()),
7052 Some(runtime_policy),
7053 SpawnRecordPolicy::Always,
7054 Some(deadline),
7055 &required_capabilities,
7056 None,
7057 resolved.approval_level,
7058 resolved.network_allowlist.clone(),
7059 resolved.browser_profile_id.clone(),
7060 ).await;
7061 let (session, effective_runtime_policy) = match spawn {
7062 Ok(spawned) => spawned,
7063 Err(error) => {
7064 if self
7065 .cleanup_managed_worktree(&lease.lease_id, true)
7066 .await
7067 .is_err()
7068 {
7069 self.mark_managed_recovery_required(
7070 &lease.lease_id,
7071 ManagedWorktreeCleanupFailure::Backend,
7072 );
7073 }
7074 let result = Err(error);
7075 self.remember_managed_spawn_attempt(
7076 durable_v2_key,
7077 request,
7078 result.clone(),
7079 accepted_at,
7080 );
7081 return result;
7082 }
7083 };
7084 let (record_id, raw_inventory_record_id) = {
7085 let mut bindings = self.session_bindings.lock()
7086 .unwrap_or_else(|poisoned| poisoned.into_inner());
7087 let binding = bindings.get_mut(&session.session.instance_id)
7088 .expect("successful spawn retains its session binding");
7089 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7090 managed_spawn_record_ownership(binding, effective_runtime_policy)
7091 };
7092 let bound = self.managed_worktrees.lock()
7093 .unwrap_or_else(|poisoned| poisoned.into_inner())
7094 .bind_session(
7095 &lease.lease_id,
7096 ManagedWorktreeSessionHolder {
7097 incarnation_id: self.incarnation_id,
7098 instance_id: session.session.instance_id,
7099 generation: session.session.generation,
7100 },
7101 record_id,
7102 unix_time_ms(),
7103 )
7104 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7105 let snapshot = match bound {
7106 Ok(snapshot) => snapshot,
7107 Err(error) => {
7108 if raw_inventory_record_id
7109 .as_ref()
7110 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7111 {
7112 self.mark_managed_recovery_required(
7113 &lease.lease_id,
7114 ManagedWorktreeCleanupFailure::Backend,
7115 );
7116 }
7117 let _ = self.remove_session(&session).await;
7118 self.mark_managed_recovery_required(
7119 &lease.lease_id,
7120 ManagedWorktreeCleanupFailure::Backend,
7121 );
7122 let result = Err(error);
7123 self.remember_managed_spawn_attempt(
7124 durable_v2_key,
7125 request,
7126 result.clone(),
7127 accepted_at,
7128 );
7129 return result;
7130 }
7131 };
7132 if let Err(error) = self.persist_state() {
7133 if raw_inventory_record_id
7134 .as_ref()
7135 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7136 {
7137 self.mark_managed_recovery_required(
7138 &lease.lease_id,
7139 ManagedWorktreeCleanupFailure::Backend,
7140 );
7141 }
7142 let _ = self.remove_session(&session).await;
7143 if self
7144 .cleanup_managed_worktree(&lease.lease_id, true)
7145 .await
7146 .is_err()
7147 {
7148 self.mark_managed_recovery_required(
7149 &lease.lease_id,
7150 ManagedWorktreeCleanupFailure::Backend,
7151 );
7152 }
7153 let result = Err(persistence_failure(error));
7154 self.remember_managed_spawn_attempt(
7155 durable_v2_key,
7156 request,
7157 result.clone(),
7158 accepted_at,
7159 );
7160 return result;
7161 }
7162 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7163 let receipt = managed_spawn_receipt(
7164 &resolved,
7165 self.incarnation_id,
7166 session,
7167 snapshot,
7168 environment_profile,
7169 bundle,
7170 context,
7171 );
7172 let result = Ok(receipt);
7173 self.remember_managed_spawn_attempt(
7174 durable_v2_key,
7175 request,
7176 result.clone(),
7177 accepted_at,
7178 );
7179 result
7180 }
7181
7182 async fn spawn_managed_worktree_v2(
7183 &self,
7184 request: ManagedWorktreeSpawnRequestV2,
7185 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7186 if request.spawn_spec.target.node_id != self.node_id {
7187 return Err(failure(
7188 NodeFailureCode::SpawnTargetMismatch,
7189 "spawn target does not match this node",
7190 ));
7191 }
7192 if request.spawn_spec.target.worktree_id.is_some() {
7193 return Err(failure(
7194 NodeFailureCode::InvalidRequest,
7195 "managed worktree spawn must not select a caller-provided worktree",
7196 ));
7197 }
7198 let request_digest = managed_spawn_request_digest_v2(&request)?;
7199 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7200 match replay {
7201 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7202 DurableManagedSpawnReplayDecision::PendingLinked => {
7203 return Err(failure(
7204 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7205 "managed worktree V2 reservation has an incomplete durable allocation",
7206 ));
7207 }
7208 DurableManagedSpawnReplayDecision::Reserved => {}
7209 }
7210 let validated = (|| {
7211 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7212 let profile = self.managed_profile(
7213 &resolved.target.workspace_id,
7214 &request.worktree_profile_id,
7215 )?;
7216 if profile.revision() != &request.expected_profile_revision {
7217 return Err(failure(
7218 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7219 "managed worktree profile revision does not match the loaded profile",
7220 ));
7221 }
7222 Ok(())
7223 })();
7224 match validated {
7225 Ok(()) => {}
7226 Err(error) => {
7227 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7228 return Err(error);
7229 }
7230 }
7231 let legacy = ManagedWorktreeSpawnRequest {
7232 spawn_spec: request.spawn_spec.clone(),
7233 worktree_profile_id: request.worktree_profile_id.clone(),
7234 };
7235 match self
7236 .spawn_managed_worktree_inner(
7237 legacy,
7238 Some(&request.spawn_spec.idempotency_key),
7239 )
7240 .await
7241 {
7242 Ok(receipt) => {
7243 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7244 Ok(receipt)
7245 }
7246 Err(error) => {
7247 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248 Err(error)
7249 }
7250 }
7251 }
7252
7253 fn managed_profile(
7254 &self,
7255 workspace_id: &WorkspaceId,
7256 profile_id: &WorktreeProfileId,
7257 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7258 if self.worktree_service_modes.get(workspace_id).copied()
7259 != Some(WorktreeServiceMode::Managed)
7260 {
7261 return Err(failure(
7262 NodeFailureCode::UnsupportedSpawnCapability,
7263 "workspace does not enable managed worktrees",
7264 ));
7265 }
7266 self.managed_worktree_profiles.get(workspace_id)
7267 .and_then(|profiles| profiles.get(profile_id))
7268 .cloned()
7269 .ok_or_else(|| failure(
7270 NodeFailureCode::InvalidRequest,
7271 "managed worktree profile is unavailable for this workspace",
7272 ))
7273 }
7274
7275 async fn reconcile_failed_allocation(
7276 &self,
7277 lease: &ManagedWorktreeLeaseRecord,
7278 source_root: &str,
7279 ) {
7280 match list_git_worktrees(source_root).await {
7281 Ok(worktrees) => {
7282 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7283 None => {
7284 match std::fs::symlink_metadata(&lease.target_root) {
7285 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7286 self.managed_worktrees.lock()
7287 .unwrap_or_else(|poisoned| poisoned.into_inner())
7288 .remove_unmutated(&lease.lease_id);
7289 let _ = self.persist_state();
7290 }
7291 _ => self.mark_managed_recovery_required(
7292 &lease.lease_id,
7293 ManagedWorktreeCleanupFailure::OwnershipConflict,
7294 ),
7295 }
7296 }
7297 Some(item) if exact_owned_worktree(
7298 lease,
7299 &item.path,
7300 item.branch.as_deref(),
7301 &item.head,
7302 ) => {
7303 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7304 }
7305 Some(_) => self.mark_managed_recovery_required(
7306 &lease.lease_id,
7307 ManagedWorktreeCleanupFailure::OwnershipConflict,
7308 ),
7309 }
7310 }
7311 Err(_) => self.mark_managed_recovery_required(
7312 &lease.lease_id,
7313 ManagedWorktreeCleanupFailure::Backend,
7314 ),
7315 }
7316 }
7317
7318 async fn reconcile_managed_worktrees(&self) {
7319 let leases = self.managed_worktrees.lock()
7320 .unwrap_or_else(|poisoned| poisoned.into_inner())
7321 .active_records()
7322 .cloned()
7323 .collect::<Vec<_>>();
7324 for lease in leases {
7325 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7326 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7327 _ => {
7328 self.mark_managed_recovery_required(
7329 &lease.lease_id,
7330 ManagedWorktreeCleanupFailure::OwnershipConflict,
7331 );
7332 continue;
7333 }
7334 };
7335 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7336 self.mark_managed_recovery_required(
7337 &lease.lease_id,
7338 ManagedWorktreeCleanupFailure::OwnershipConflict,
7339 );
7340 continue;
7341 }
7342 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7343 Ok(root) => root,
7344 Err(_) => {
7345 self.mark_managed_recovery_required(
7346 &lease.lease_id,
7347 ManagedWorktreeCleanupFailure::OwnershipConflict,
7348 );
7349 continue;
7350 }
7351 };
7352 let worktrees = match list_git_worktrees(&source_root).await {
7353 Ok(worktrees) => worktrees,
7354 Err(_) => {
7355 self.mark_managed_recovery_required(
7356 &lease.lease_id,
7357 ManagedWorktreeCleanupFailure::Backend,
7358 );
7359 continue;
7360 }
7361 };
7362 let listed = worktrees.iter()
7363 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7364 let Some(listed) = listed else {
7365 match std::fs::symlink_metadata(&lease.target_root) {
7366 Err(error) if error.kind() == io::ErrorKind::NotFound
7367 && lease.state == ManagedWorktreeLeaseState::Allocating
7368 && !lease.has_holders() => {
7369 let removed = self.managed_worktrees.lock()
7370 .unwrap_or_else(|poisoned| poisoned.into_inner())
7371 .tombstone(&lease.lease_id, unix_time_ms());
7372 if self.persist_state().is_ok() && removed.is_some() {
7373 self.publish(NodeEvent::ManagedWorktreeRemoved {
7374 lease_id: lease.lease_id.clone(),
7375 });
7376 }
7377 }
7378 _ => self.mark_managed_recovery_required(
7379 &lease.lease_id,
7380 ManagedWorktreeCleanupFailure::OwnershipConflict,
7381 ),
7382 }
7383 continue;
7384 };
7385 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7386 &lease,
7387 &listed.path,
7388 listed.branch.as_deref(),
7389 &listed.head,
7390 ) {
7391 self.mark_managed_recovery_required(
7392 &lease.lease_id,
7393 ManagedWorktreeCleanupFailure::OwnershipConflict,
7394 );
7395 continue;
7396 }
7397 if !self.workspaces.read()
7398 .unwrap_or_else(|poisoned| poisoned.into_inner())
7399 .contains_key(&lease.workspace_id)
7400 {
7401 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7402 self.mark_managed_recovery_required(
7403 &lease.lease_id,
7404 ManagedWorktreeCleanupFailure::Backend,
7405 );
7406 continue;
7407 }
7408 }
7409 let snapshot = {
7410 let mut registry = self.managed_worktrees.lock()
7411 .unwrap_or_else(|poisoned| poisoned.into_inner());
7412 let current = registry.get_mut(&lease.lease_id)
7413 .expect("reconciled managed lease remains present");
7414 current.expected_head = Some(listed.head.clone());
7415 current.cleanup_failure = None;
7416 current.state = if current.has_holders() {
7417 ManagedWorktreeLeaseState::InUse
7418 } else if current.retention == ManagedWorktreeRetention::Retain {
7419 ManagedWorktreeLeaseState::Retained
7420 } else {
7421 ManagedWorktreeLeaseState::Ready
7422 };
7423 current.updated_at_unix_ms = unix_time_ms();
7424 current.snapshot()
7425 };
7426 if self.persist_state().is_err() {
7427 self.mark_managed_recovery_required(
7428 &lease.lease_id,
7429 ManagedWorktreeCleanupFailure::Backend,
7430 );
7431 continue;
7432 }
7433 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7434 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7435 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7436 }
7437 }
7438 }
7439
7440 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7460 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7461 .session_records
7462 .lock()
7463 .unwrap_or_else(|poisoned| poisoned.into_inner())
7464 .records
7465 .values()
7466 .filter(|record| {
7467 record.state == ManagedSessionState::Live
7468 && record.exported_context.is_none()
7469 && record.provider_session.is_some()
7470 })
7471 .filter_map(|record| {
7472 record
7473 .active_session
7474 .clone()
7475 .map(|session| (record.record_id.clone(), session))
7476 })
7477 .collect();
7478 for (record_id, session) in candidates {
7479 let shared = Arc::clone(self);
7480 tokio::spawn(async move {
7481 shared
7482 .reconcile_context_pack_export_for_record(&record_id, session)
7483 .await;
7484 });
7485 }
7486 }
7487
7488 async fn reconcile_context_pack_export_for_record(
7507 &self,
7508 record_id: &SessionRecordId,
7509 session: SessionAddress,
7510 ) {
7511 let Ok(record) = self.record(record_id) else {
7512 return;
7513 };
7514 if !matches!(
7515 record.state,
7516 ManagedSessionState::Live | ManagedSessionState::Dormant
7517 ) || record.exported_context.is_some()
7518 || record.provider_session.is_none()
7519 {
7520 return;
7521 }
7522 let Ok(receipt) = self
7529 .export_context_pack_for_session_record(&record.record_id, &session, true)
7530 .await
7531 else {
7532 return;
7533 };
7534 self.set_exported_context(&record.record_id, receipt);
7535 }
7536
7537 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7542 let transaction = self
7543 .state_transaction
7544 .lock()
7545 .unwrap_or_else(|poisoned| poisoned.into_inner());
7546 let (previous, updated) = {
7547 let mut records = self
7548 .session_records
7549 .lock()
7550 .unwrap_or_else(|poisoned| poisoned.into_inner());
7551 let Some(record) = records.records.get_mut(record_id) else {
7552 return;
7553 };
7554 if record.exported_context.is_some() {
7555 return;
7556 }
7557 let previous = record.clone();
7558 record.exported_context = Some(receipt);
7559 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7560 (previous, record.clone())
7561 };
7562 if self.persist_state_locked().is_err() {
7563 self.session_records
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner())
7566 .records
7567 .insert(record_id.clone(), previous);
7568 return;
7569 }
7570 drop(transaction);
7571 self.publish_record(updated);
7572 }
7573
7574 fn managed_record_export_target_for_instance(
7584 &self,
7585 instance_id: AgentInstanceId,
7586 generation: SessionGeneration,
7587 ) -> Option<(SessionRecordId, SessionAddress)> {
7588 let bindings = self
7589 .session_bindings
7590 .lock()
7591 .unwrap_or_else(|poisoned| poisoned.into_inner());
7592 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7593 let record_id = binding.record_id.clone()?;
7594 Some((
7595 record_id,
7596 SessionAddress {
7597 workspace_id: binding.workspace_id.clone(),
7598 session: SessionKey { instance_id, generation },
7599 },
7600 ))
7601 }
7602
7603 fn mark_managed_recovery_required(
7604 &self,
7605 lease_id: &ManagedWorktreeLeaseId,
7606 failure_kind: ManagedWorktreeCleanupFailure,
7607 ) {
7608 let snapshot = {
7609 let mut registry = self.managed_worktrees.lock()
7610 .unwrap_or_else(|poisoned| poisoned.into_inner());
7611 let Some(lease) = registry.get_mut(lease_id) else { return };
7612 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7613 lease.cleanup_failure = Some(failure_kind);
7614 lease.updated_at_unix_ms = unix_time_ms();
7615 lease.snapshot()
7616 };
7617 let _ = self.persist_state();
7618 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7619 }
7620
7621 fn set_persistence_error(&self, error: Option<String>) {
7622 *self
7623 .persistence_error
7624 .write()
7625 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7626 .map(|message| session_registry::sanitized_persistence_summary(&message));
7627 }
7628
7629 fn persist_state(&self) -> io::Result<()> {
7630 let _transaction = self
7631 .state_transaction
7632 .lock()
7633 .unwrap_or_else(|poisoned| poisoned.into_inner());
7634 self.persist_state_locked()
7635 }
7636
7637 fn persist_state_locked(&self) -> io::Result<()> {
7638 let workspaces = self
7639 .workspaces
7640 .read()
7641 .unwrap_or_else(|poisoned| poisoned.into_inner())
7642 .clone();
7643 let records = self
7644 .session_records
7645 .lock()
7646 .unwrap_or_else(|poisoned| poisoned.into_inner())
7647 .records
7648 .values()
7649 .cloned()
7650 .collect::<Vec<_>>();
7651 let mut managed = self.managed_worktrees
7652 .lock()
7653 .unwrap_or_else(|poisoned| poisoned.into_inner());
7654 managed.reattach_record_holders(
7655 &managed_worktree_record_holders(&records),
7656 unix_time_ms(),
7657 );
7658 let materializations = self
7659 .materializations
7660 .lock()
7661 .unwrap_or_else(|poisoned| poisoned.into_inner())
7662 .values()
7663 .cloned()
7664 .collect::<Vec<_>>();
7665 let managed_spawn_replays = self.managed_spawn_replays
7666 .lock()
7667 .unwrap_or_else(|poisoned| poisoned.into_inner())
7668 .values()
7669 .cloned()
7670 .collect::<Vec<_>>();
7671 let result = session_registry::save_v10(
7672 self.state_path.as_deref(),
7673 &self.node_id,
7674 &workspaces,
7675 &records,
7676 &managed.records(),
7677 &managed.tombstones(),
7678 &materializations,
7679 &managed_spawn_replays,
7680 );
7681 match &result {
7682 Ok(warning) => self.set_persistence_error(warning.clone()),
7683 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7684 }
7685 result.map(|_| ())
7686 }
7687
7688 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7689 let _mutation_guard = self.mutation_gate.lock().await;
7690 self.begin_shutdown_locked().await
7691 }
7692
7693 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7694 if self.shutdown.swap(true, Ordering::AcqRel) {
7695 return Ok(());
7696 }
7697 SessionLifecycleHook::on_shutdown(self);
7698 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7699 let mut first_error = None;
7700 for session in &self.handle.snapshot().sessions {
7701 let remaining = deadline.saturating_duration_since(Instant::now());
7702 if remaining.is_zero() {
7703 first_error.get_or_insert_with(|| {
7704 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7705 });
7706 break;
7707 }
7708 if let Err(error) = self
7709 .dispatch_bounded(
7710 ControlCommand::Stop {
7711 instance_id: session.instance_id,
7712 force: true,
7713 },
7714 remaining,
7715 )
7716 .await
7717 {
7718 first_error.get_or_insert(error);
7719 }
7720 }
7721 self.shutdown_notify.notify_waiters();
7722 match first_error {
7723 Some(error) => Err(error),
7724 None => Ok(()),
7725 }
7726 }
7727
7728 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7729 let bindings = self
7730 .session_bindings
7731 .lock()
7732 .unwrap_or_else(|poisoned| poisoned.into_inner());
7733 if bindings.len() >= CONTROL_SESSIONS_MAX {
7734 Err(failure(
7735 NodeFailureCode::BackendBusy,
7736 "node session-binding capacity is full; remove an existing session first",
7737 ))
7738 } else {
7739 Ok(())
7740 }
7741 }
7742
7743 fn ensure_workspace_environment_profile_compatible(
7750 &self,
7751 workspace_id: &WorkspaceId,
7752 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7753 ) -> Result<(), NodeFailure> {
7754 let bindings = self
7755 .session_bindings
7756 .lock()
7757 .unwrap_or_else(|poisoned| poisoned.into_inner());
7758 for binding in bindings.values() {
7759 if &binding.workspace_id != workspace_id {
7760 continue;
7761 }
7762 if binding.environment_profile.as_ref() != environment_profile {
7763 return Err(failure(
7764 NodeFailureCode::WorkspaceBusy,
7765 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7766 ));
7767 }
7768 }
7769 Ok(())
7770 }
7771
7772 #[cfg(test)]
7773 fn bind_session(
7774 &self,
7775 instance_id: AgentInstanceId,
7776 workspace_id: WorkspaceId,
7777 generation: SessionGeneration,
7778 ) {
7779 self.bind_session_with_policy(
7780 &SessionAddress {
7781 workspace_id,
7782 session: SessionKey { instance_id, generation },
7783 },
7784 ProviderRuntimePolicy::raw_pty(),
7785 None,
7786 );
7787 }
7788
7789 #[cfg(test)]
7790 fn bind_session_with_policy(
7791 &self,
7792 address: &SessionAddress,
7793 runtime_policy: ProviderRuntimePolicy,
7794 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795 ) {
7796 self.bind_session_with_materialization(
7797 address,
7798 runtime_policy,
7799 environment_profile,
7800 None,
7801 None,
7802 None,
7803 );
7804 }
7805
7806 fn bind_session_with_materialization(
7807 &self,
7808 address: &SessionAddress,
7809 runtime_policy: ProviderRuntimePolicy,
7810 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7811 bundle: Option<ResolvedBundleReceipt>,
7812 context: Option<ResolvedContextPackReceipt>,
7813 materialization_id: Option<MaterializationId>,
7814 ) {
7815 let mut bindings = self
7816 .session_bindings
7817 .lock()
7818 .unwrap_or_else(|poisoned| poisoned.into_inner());
7819 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7820 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7821 bindings.insert(address.session.instance_id, SessionBinding {
7822 workspace_id: address.workspace_id.clone(),
7823 generation: address.session.generation,
7824 runtime_policy,
7825 pending_resume: None,
7826 record_id: None,
7827 managed_worktree_lease_id: None,
7828 environment_profile,
7829 bundle,
7830 context,
7831 materialization_id,
7832 });
7833 }
7834
7835 #[cfg(test)]
7836 fn bind_managed_session(
7837 &self,
7838 address: &SessionAddress,
7839 record_id: SessionRecordId,
7840 runtime_policy: ProviderRuntimePolicy,
7841 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7842 ) {
7843 self.bind_managed_session_with_materialization(
7844 address,
7845 record_id,
7846 runtime_policy,
7847 environment_profile,
7848 None,
7849 None,
7850 None,
7851 );
7852 }
7853
7854 fn bind_managed_session_with_materialization(
7855 &self,
7856 address: &SessionAddress,
7857 record_id: SessionRecordId,
7858 runtime_policy: ProviderRuntimePolicy,
7859 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7860 bundle: Option<ResolvedBundleReceipt>,
7861 context: Option<ResolvedContextPackReceipt>,
7862 materialization_id: Option<MaterializationId>,
7863 ) {
7864 let mut bindings = self
7865 .session_bindings
7866 .lock()
7867 .unwrap_or_else(|poisoned| poisoned.into_inner());
7868 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7869 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7870 bindings.insert(
7871 address.session.instance_id,
7872 SessionBinding {
7873 workspace_id: address.workspace_id.clone(),
7874 generation: address.session.generation,
7875 runtime_policy,
7876 pending_resume: None,
7877 record_id: Some(record_id),
7878 managed_worktree_lease_id: None,
7879 environment_profile,
7880 bundle,
7881 context,
7882 materialization_id,
7883 },
7884 );
7885 }
7886
7887 #[cfg(test)]
7888 fn bind_spawn_session(
7889 &self,
7890 address: &SessionAddress,
7891 provider: AgentId,
7892 mode: SessionMode,
7893 runtime_policy: ProviderRuntimePolicy,
7894 record_policy: SpawnRecordPolicy,
7895 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7896 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7897 self.bind_spawn_session_with_materialization(
7898 address,
7899 provider,
7900 mode,
7901 runtime_policy,
7902 record_policy,
7903 environment_profile,
7904 None,
7905 None,
7906 None,
7907 )
7908 }
7909
7910 fn bind_spawn_session_with_materialization(
7911 &self,
7912 address: &SessionAddress,
7913 provider: AgentId,
7914 mode: SessionMode,
7915 runtime_policy: ProviderRuntimePolicy,
7916 record_policy: SpawnRecordPolicy,
7917 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7918 bundle: Option<ResolvedBundleReceipt>,
7919 context: Option<ResolvedContextPackReceipt>,
7920 materialization_id: Option<MaterializationId>,
7921 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7922 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7923 && !runtime_policy.provider_session_identity
7924 {
7925 self.bind_session_with_materialization(
7926 address,
7927 runtime_policy,
7928 environment_profile,
7929 bundle,
7930 context,
7931 materialization_id,
7932 );
7933 return Ok(None);
7934 }
7935 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7936 let record = self.new_record(
7937 address,
7938 provider,
7939 mode,
7940 active_record_state(runtime_policy.provider_session_identity, false),
7941 None,
7942 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7943 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7944 record_owns_lifecycle.then(|| context.clone()).flatten(),
7945 )?;
7946 let record_id = record.record_id.clone();
7947 let transaction = self
7948 .state_transaction
7949 .lock()
7950 .unwrap_or_else(|poisoned| poisoned.into_inner());
7951 self.insert_record(record.clone())?;
7952 let previous_materialization = if record_owns_lifecycle {
7953 if let Some(materialization_id) = materialization_id.as_ref() {
7954 let mut materializations = self
7955 .materializations
7956 .lock()
7957 .unwrap_or_else(|poisoned| poisoned.into_inner());
7958 let Some(ownership) = materializations.get_mut(materialization_id) else {
7959 drop(materializations);
7960 self.remove_record_memory(&record_id);
7961 return Err(failure(
7962 NodeFailureCode::BackendOperationFailed,
7963 "session environment ownership disappeared before binding",
7964 ));
7965 };
7966 let previous = ownership.clone();
7967 if ownership
7968 .transfer_to_record(record_id.clone(), unix_time_ms())
7969 .is_err()
7970 {
7971 drop(materializations);
7972 self.remove_record_memory(&record_id);
7973 return Err(failure(
7974 NodeFailureCode::BackendOperationFailed,
7975 "session environment ownership transfer failed",
7976 ));
7977 }
7978 Some(previous)
7979 } else {
7980 None
7981 }
7982 } else {
7983 None
7984 };
7985 self.bind_managed_session_with_materialization(
7986 address,
7987 record_id.clone(),
7988 runtime_policy,
7989 environment_profile,
7990 bundle,
7991 context,
7992 materialization_id.clone(),
7993 );
7994 if let Err(error) = self.persist_state_locked() {
7995 self.remove_binding(address);
7996 self.remove_record_memory(&record_id);
7997 if let (Some(materialization_id), Some(previous)) =
7998 (materialization_id.as_ref(), previous_materialization)
7999 {
8000 self.materializations
8001 .lock()
8002 .unwrap_or_else(|poisoned| poisoned.into_inner())
8003 .insert(materialization_id.clone(), previous);
8004 }
8005 return Err(persistence_failure(error));
8006 }
8007 drop(transaction);
8008 self.publish_record(record);
8009 Ok(Some(record_id))
8010 }
8011
8012 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8013 for _ in 0..8 {
8014 let bytes = random_nonce().map_err(|error| {
8015 failure(
8016 NodeFailureCode::BackendOperationFailed,
8017 &format!("session record identity generation failed: {error}"),
8018 )
8019 })?;
8020 let mut value = String::with_capacity(27);
8021 value.push_str("sr-");
8022 for byte in &bytes[..12] {
8023 use std::fmt::Write as _;
8024 let _ = write!(&mut value, "{byte:02x}");
8025 }
8026 let record_id = SessionRecordId::new(value).map_err(|error| {
8027 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8028 })?;
8029 let records = self
8030 .session_records
8031 .lock()
8032 .unwrap_or_else(|poisoned| poisoned.into_inner());
8033 if !records.records.contains_key(&record_id) {
8034 return Ok(record_id);
8035 }
8036 }
8037 Err(failure(
8038 NodeFailureCode::BackendBusy,
8039 "could not allocate a unique session record ID",
8040 ))
8041 }
8042
8043 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8044 for _ in 0..8 {
8045 let bytes = random_nonce().map_err(|error| {
8046 failure(
8047 NodeFailureCode::BackendOperationFailed,
8048 &format!("task identity generation failed: {error}"),
8049 )
8050 })?;
8051 let mut nonce = [0_u8; 12];
8052 nonce.copy_from_slice(&bytes[..12]);
8053 let task_id = TaskId::from_nonce(nonce);
8054 let records = self
8055 .session_records
8056 .lock()
8057 .unwrap_or_else(|poisoned| poisoned.into_inner());
8058 if !records.records.values().any(|record| {
8059 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8060 == Some(&task_id)
8061 }) {
8062 return Ok(task_id);
8063 }
8064 }
8065 Err(failure(
8066 NodeFailureCode::BackendBusy,
8067 "could not allocate a unique task ID",
8068 ))
8069 }
8070
8071 fn default_record_name(&self, provider: &AgentId) -> String {
8072 let records = self
8073 .session_records
8074 .lock()
8075 .unwrap_or_else(|poisoned| poisoned.into_inner());
8076 let ordinal = records
8077 .records
8078 .values()
8079 .filter(|record| &record.provider == provider)
8080 .count()
8081 .saturating_add(1);
8082 format!("{} #{ordinal}", provider.as_str())
8083 }
8084
8085 fn new_record(
8086 &self,
8087 address: &SessionAddress,
8088 provider: AgentId,
8089 mode: SessionMode,
8090 state: ManagedSessionState,
8091 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8092 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8093 bundle: Option<ResolvedBundleReceipt>,
8094 context: Option<ResolvedContextPackReceipt>,
8095 ) -> Result<ManagedSessionRecord, NodeFailure> {
8096 let canonical_root = self.workspace_root(&address.workspace_id)?;
8097 let now = unix_time_ms();
8098 Ok(ManagedSessionRecord {
8099 record_id: self.allocate_record_id()?,
8100 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8101 provider,
8102 mode,
8103 state,
8104 workspace_id: address.workspace_id.clone(),
8105 canonical_root: opaque_windows_path(canonical_root),
8106 provider_session,
8107 active_session: Some(address.clone()),
8108 environment_profile,
8109 bundle,
8110 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8111 context,
8112 exported_context: None,
8113 task_binding: None,
8114 created_at_unix_ms: now,
8115 updated_at_unix_ms: now,
8116 last_error: None,
8117 })
8118 }
8119
8120 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8121 let mut records = self
8122 .session_records
8123 .lock()
8124 .unwrap_or_else(|poisoned| poisoned.into_inner());
8125 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8126 return Err(failure(
8127 NodeFailureCode::BackendBusy,
8128 "managed session record capacity is full; forget an inactive record first",
8129 ));
8130 }
8131 if let Some(identity) = record.provider_session.as_ref() {
8132 if records.records.values().any(|candidate| {
8133 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134 session_registry::same_provider_session(
8135 &candidate.provider,
8136 candidate_identity,
8137 &record.provider,
8138 identity,
8139 )
8140 })
8141 }) {
8142 return Err(failure(
8143 NodeFailureCode::SessionRecordConflict,
8144 "provider session is already represented by another managed record",
8145 ));
8146 }
8147 }
8148 match records.records.entry(record.record_id.clone()) {
8149 std::collections::btree_map::Entry::Vacant(entry) => {
8150 entry.insert(record);
8151 }
8152 std::collections::btree_map::Entry::Occupied(_) => {
8153 return Err(failure(
8154 NodeFailureCode::SessionRecordConflict,
8155 "session record ID collision",
8156 ));
8157 }
8158 }
8159 Ok(())
8160 }
8161
8162 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8163 self.session_records
8164 .lock()
8165 .unwrap_or_else(|poisoned| poisoned.into_inner())
8166 .records
8167 .get(record_id)
8168 .cloned()
8169 .ok_or_else(|| {
8170 failure(
8171 NodeFailureCode::UnknownSessionRecord,
8172 "managed session record does not exist",
8173 )
8174 })
8175 }
8176
8177 fn revalidate_session_record_preview(
8178 &self,
8179 expected: &ManagedSessionRecord,
8180 identity: &ProviderSessionIdentity,
8181 ) -> Result<(), NodeFailure> {
8182 let current = self.record(&expected.record_id)?;
8183 let current_root = self.workspace_root(¤t.workspace_id)?;
8184 if current.provider != expected.provider
8185 || current.workspace_id != expected.workspace_id
8186 || current.canonical_root != expected.canonical_root
8187 || current.provider_session.as_ref() != Some(identity)
8188 || !platform::roots_equal(
8189 ¤t_root,
8190 windows_path_text(¤t.canonical_root),
8191 )
8192 {
8193 return Err(failure(
8194 NodeFailureCode::SessionRecordConflict,
8195 "managed session changed while its preview was loading",
8196 ));
8197 }
8198 Ok(())
8199 }
8200
8201 fn revalidate_session_record_context_export(
8202 &self,
8203 expected: &ManagedSessionRecord,
8204 identity: Option<&ProviderSessionIdentity>,
8205 session: &SessionAddress,
8206 allow_clean_detachment: bool,
8207 ) -> Result<(), NodeFailure> {
8208 let current = self.record(&expected.record_id)?;
8209 let current_root = self.workspace_root(¤t.workspace_id)?;
8210 let snapshot = self.internal_session_snapshot(session)?;
8211 let binding = self
8212 .session_bindings
8213 .lock()
8214 .unwrap_or_else(|poisoned| poisoned.into_inner())
8215 .get(&session.session.instance_id)
8216 .cloned()
8217 .ok_or_else(|| {
8218 failure(
8219 NodeFailureCode::SessionRecordConflict,
8220 "managed session runtime binding disappeared during context export",
8221 )
8222 })?;
8223 if !session_record_context_export_binding_is_exact(
8224 expected,
8225 ¤t,
8226 identity,
8227 session,
8228 &binding,
8229 &snapshot.agent_id,
8230 ¤t_root,
8231 allow_clean_detachment,
8232 ) || !session_record_context_export_source_is_usable(
8233 ¤t,
8234 &snapshot.status,
8235 allow_clean_detachment,
8236 ) {
8237 return Err(failure(
8238 NodeFailureCode::SessionRecordConflict,
8239 "managed session binding changed during context export",
8240 ));
8241 }
8242 Ok(())
8243 }
8244
8245 fn revalidate_loaded_history(
8246 &self,
8247 session: &SessionAddress,
8248 candidate_id: &str,
8249 loaded: &HistorySessionRecord,
8250 ) -> Result<(), NodeFailure> {
8251 let snapshot = self.internal_session_snapshot(session)?;
8252 if snapshot.history.pending.is_some()
8253 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8254 || snapshot.history.loaded.as_ref() != Some(loaded)
8255 {
8256 return Err(failure(
8257 NodeFailureCode::SessionRecordConflict,
8258 "managed session history changed during context export",
8259 ));
8260 }
8261 Ok(())
8262 }
8263
8264 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8265 self.session_records
8266 .lock()
8267 .unwrap_or_else(|poisoned| poisoned.into_inner())
8268 .records
8269 .remove(record_id)
8270 }
8271
8272 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8273 if self.destroy_record_materialization(record_id)? {
8274 self.publish(NodeEvent::SessionRecordRemoved {
8275 record_id: record_id.clone(),
8276 });
8277 return Ok(());
8278 }
8279 let transaction = self
8280 .state_transaction
8281 .lock()
8282 .unwrap_or_else(|poisoned| poisoned.into_inner());
8283 let Some(removed) = self.remove_record_memory(record_id) else {
8284 return Ok(());
8285 };
8286 if let Err(error) = self.persist_state_locked() {
8287 let _ = self.insert_record(removed);
8288 return Err(persistence_failure(error));
8289 }
8290 drop(transaction);
8291 self.publish(NodeEvent::SessionRecordRemoved {
8292 record_id: record_id.clone(),
8293 });
8294 Ok(())
8295 }
8296
8297 fn publish_record(&self, record: ManagedSessionRecord) {
8298 self.publish(NodeEvent::SessionRecordUpserted { record });
8299 }
8300
8301 fn mark_record_error(
8302 &self,
8303 record_id: &SessionRecordId,
8304 message: &str,
8305 ) -> Result<(), NodeFailure> {
8306 let transaction = self
8307 .state_transaction
8308 .lock()
8309 .unwrap_or_else(|poisoned| poisoned.into_inner());
8310 let previous = {
8311 let mut records = self
8312 .session_records
8313 .lock()
8314 .unwrap_or_else(|poisoned| poisoned.into_inner());
8315 let Some(record) = records.records.get_mut(record_id) else {
8316 return Ok(());
8317 };
8318 let previous = record.clone();
8319 record.state = ManagedSessionState::Unavailable;
8320 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8321 record.updated_at_unix_ms = unix_time_ms();
8322 previous
8323 };
8324 if let Err(error) = self.persist_state_locked() {
8325 self.session_records
8326 .lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner())
8328 .records
8329 .insert(record_id.clone(), previous);
8330 return Err(persistence_failure(error));
8331 }
8332 let record = self.record(record_id)?;
8333 drop(transaction);
8334 self.publish_record(record);
8335 Ok(())
8336 }
8337
8338 fn rename_session_record(
8339 &self,
8340 record_id: &SessionRecordId,
8341 display_name: String,
8342 ) -> Result<ManagedSessionRecord, NodeFailure> {
8343 validate_display_name(&display_name)
8344 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8345 let _transaction = self
8346 .state_transaction
8347 .lock()
8348 .unwrap_or_else(|poisoned| poisoned.into_inner());
8349 let previous = {
8350 let mut records = self
8351 .session_records
8352 .lock()
8353 .unwrap_or_else(|poisoned| poisoned.into_inner());
8354 let record = records.records.get_mut(record_id).ok_or_else(|| {
8355 failure(
8356 NodeFailureCode::UnknownSessionRecord,
8357 "managed session record does not exist",
8358 )
8359 })?;
8360 let previous = record.clone();
8361 record.display_name = display_name;
8362 record.updated_at_unix_ms = unix_time_ms();
8363 previous
8364 };
8365 if let Err(error) = self.persist_state_locked() {
8366 self.session_records
8367 .lock()
8368 .unwrap_or_else(|poisoned| poisoned.into_inner())
8369 .records
8370 .insert(record_id.clone(), previous);
8371 return Err(persistence_failure(error));
8372 }
8373 let updated = self.record(record_id)?;
8374 self.publish_record(updated.clone());
8375 Ok(updated)
8376 }
8377
8378 fn set_session_task(
8379 &self,
8380 record_id: &SessionRecordId,
8381 expected_revision: u64,
8382 target: SessionTaskTargetV1,
8383 ) -> Result<ManagedSessionRecord, NodeFailure> {
8384 let transaction = self
8385 .state_transaction
8386 .lock()
8387 .unwrap_or_else(|poisoned| poisoned.into_inner());
8388 let current = self.record(record_id)?;
8389 let current_task_id = current
8390 .task_binding
8391 .as_ref()
8392 .and_then(|binding| binding.task_id.as_ref());
8393 let current_revision = current
8394 .task_binding
8395 .as_ref()
8396 .map_or(0, |binding| binding.revision);
8397 if expected_revision != current_revision {
8398 return Err(failure(
8399 NodeFailureCode::SessionRecordConflict,
8400 "managed session task binding revision changed",
8401 ));
8402 }
8403 let exact = match &target {
8404 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8405 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8406 SessionTaskTargetV1::New => false,
8407 };
8408 if exact {
8409 return Ok(current);
8410 }
8411 let revision = current_revision.checked_add(1).ok_or_else(|| {
8412 failure(
8413 NodeFailureCode::SessionRecordConflict,
8414 "managed session task binding revision is exhausted",
8415 )
8416 })?;
8417 let task_id = match target {
8418 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8419 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8420 SessionTaskTargetV1::Clear => None,
8421 };
8422 let changed_at_unix_ms = unix_time_ms()
8423 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8424 failure(
8425 NodeFailureCode::SessionRecordConflict,
8426 "managed session record timestamp is exhausted",
8427 )
8428 })?);
8429 let updated = {
8430 let mut records = self
8431 .session_records
8432 .lock()
8433 .unwrap_or_else(|poisoned| poisoned.into_inner());
8434 let record = records.records.get_mut(record_id).ok_or_else(|| {
8435 failure(
8436 NodeFailureCode::UnknownSessionRecord,
8437 "managed session record does not exist",
8438 )
8439 })?;
8440 record.task_binding = Some(SessionTaskBindingV1 {
8441 revision,
8442 task_id,
8443 changed_at_unix_ms,
8444 });
8445 record.updated_at_unix_ms = changed_at_unix_ms;
8446 record.clone()
8447 };
8448 if let Err(error) = self.persist_state_locked() {
8449 self.session_records
8450 .lock()
8451 .unwrap_or_else(|poisoned| poisoned.into_inner())
8452 .records
8453 .insert(record_id.clone(), current);
8454 return Err(persistence_failure(error));
8455 }
8456 drop(transaction);
8457 self.publish_record(updated.clone());
8458 Ok(updated)
8459 }
8460
8461 fn index_provider_session(
8462 &self,
8463 workspace_id: WorkspaceId,
8464 provider: AgentId,
8465 identity: ProviderSessionIdentity,
8466 display_name: String,
8467 ) -> Result<ManagedSessionRecord, NodeFailure> {
8468 self.index_provider_session_with_policy(
8469 workspace_id,
8470 provider,
8471 identity,
8472 display_name,
8473 false,
8474 )
8475 }
8476
8477 fn index_provider_session_with_policy(
8478 &self,
8479 workspace_id: WorkspaceId,
8480 provider: AgentId,
8481 identity: ProviderSessionIdentity,
8482 display_name: String,
8483 allow_validated_transcript_path: bool,
8484 ) -> Result<ManagedSessionRecord, NodeFailure> {
8485 validate_display_name(&display_name)
8486 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8487 identity
8488 .validate()
8489 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8490 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8491 return Err(failure(
8492 NodeFailureCode::InvalidRequest,
8493 "provider session reference index v1 does not accept transcript paths",
8494 ));
8495 }
8496 let canonical_root = self.workspace_root(&workspace_id)?;
8497 if !self.enabled_providers.contains(&provider) {
8498 return Err(failure(
8499 NodeFailureCode::InvalidRequest,
8500 "provider is not enabled on this node",
8501 ));
8502 }
8503
8504 let transaction = self
8505 .state_transaction
8506 .lock()
8507 .unwrap_or_else(|poisoned| poisoned.into_inner());
8508 {
8509 let records = self
8510 .session_records
8511 .lock()
8512 .unwrap_or_else(|poisoned| poisoned.into_inner());
8513 if let Some(existing) = records.records.values().find(|candidate| {
8514 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8516 })
8517 }) {
8518 if existing.provider == provider && existing.workspace_id == workspace_id {
8519 return Ok(existing.clone());
8520 }
8521 return Err(failure(
8522 NodeFailureCode::SessionRecordConflict,
8523 "provider session reference conflicts with another provider or workspace",
8524 ));
8525 }
8526 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8527 return Err(failure(
8528 NodeFailureCode::BackendBusy,
8529 "managed session record capacity is full; forget an inactive record first",
8530 ));
8531 }
8532 }
8533
8534 let now = unix_time_ms();
8535 let record = ManagedSessionRecord {
8536 record_id: self.allocate_record_id()?,
8537 display_name,
8538 provider,
8539 mode: SessionMode::Pty,
8540 state: ManagedSessionState::Dormant,
8541 workspace_id,
8542 canonical_root: opaque_windows_path(canonical_root),
8543 provider_session: Some(identity),
8544 active_session: None,
8545 environment_profile: None,
8546 bundle: None,
8547 context_id: None,
8548 context: None,
8549 exported_context: None,
8550 task_binding: None,
8551 created_at_unix_ms: now,
8552 updated_at_unix_ms: now,
8553 last_error: None,
8554 };
8555 self.insert_record(record.clone())?;
8556 if let Err(error) = self.persist_state_locked() {
8557 self.remove_record_memory(&record.record_id);
8558 return Err(persistence_failure(error));
8559 }
8560 drop(transaction);
8561 self.publish_record(record.clone());
8562 Ok(record)
8563 }
8564
8565 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8566 self.session_bindings
8567 .lock()
8568 .unwrap_or_else(|poisoned| poisoned.into_inner())
8569 .iter()
8570 .find_map(|(instance_id, binding)| {
8571 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8572 workspace_id: binding.workspace_id.clone(),
8573 session: SessionKey {
8574 instance_id: *instance_id,
8575 generation: binding.generation,
8576 },
8577 })
8578 })
8579 }
8580
8581 async fn forget_session_record(
8582 &self,
8583 record_id: &SessionRecordId,
8584 ) -> Result<(), NodeFailure> {
8585 let record = self.record(record_id)?;
8586 if record.active_session.is_some()
8587 || matches!(
8588 record.state,
8589 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590 )
8591 {
8592 return Err(failure(
8593 NodeFailureCode::SessionRecordBusy,
8594 "stop the managed session before forgetting it",
8595 ));
8596 }
8597 if let Some(address) = self.bound_address_for_record(record_id) {
8598 self.remove_session(&address).await?;
8599 }
8600 self.remove_managed_session_record(record_id, &record.workspace_id).await
8601 }
8602
8603 async fn remove_managed_session_record(
8618 &self,
8619 record_id: &SessionRecordId,
8620 workspace_id: &WorkspaceId,
8621 ) -> Result<(), NodeFailure> {
8622 let managed_lease_id = self.managed_worktrees.lock()
8623 .unwrap_or_else(|poisoned| poisoned.into_inner())
8624 .lease_for_workspace(workspace_id);
8625 if !self.destroy_record_materialization(record_id)? {
8626 let transaction = self
8627 .state_transaction
8628 .lock()
8629 .unwrap_or_else(|poisoned| poisoned.into_inner());
8630 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8631 failure(
8632 NodeFailureCode::UnknownSessionRecord,
8633 "managed session record does not exist",
8634 )
8635 })?;
8636 if let Err(error) = self.persist_state_locked() {
8637 let _ = self.insert_record(removed);
8638 return Err(persistence_failure(error));
8639 }
8640 drop(transaction);
8641 }
8642 self.publish(NodeEvent::SessionRecordRemoved {
8643 record_id: record_id.clone(),
8644 });
8645 if let Some(lease_id) = managed_lease_id {
8646 if let Some(snapshot) = self.managed_worktrees.lock()
8647 .unwrap_or_else(|poisoned| poisoned.into_inner())
8648 .get(&lease_id)
8649 .map(ManagedWorktreeLeaseRecord::snapshot)
8650 {
8651 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8652 }
8653 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8654 }
8655 Ok(())
8656 }
8657
8658 async fn retire_unavailable_session_records(&self) {
8671 let policy = self.session_record_retention;
8672 let age_enabled = policy.age_ms > 0;
8673 let keep_enabled = policy.keep_per_workspace > 0;
8674 if !age_enabled && !keep_enabled {
8675 return;
8676 }
8677 let now = unix_time_ms();
8678 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8679 self.session_records
8680 .lock()
8681 .unwrap_or_else(|poisoned| poisoned.into_inner())
8682 .records
8683 .values()
8684 .filter(|record| record.state == ManagedSessionState::Unavailable)
8685 .map(|record| {
8686 (
8687 record.record_id.clone(),
8688 record.workspace_id.clone(),
8689 record.provider.clone(),
8690 record.updated_at_unix_ms,
8691 )
8692 })
8693 .collect()
8694 };
8695 if candidates.is_empty() {
8696 return;
8697 }
8698 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8699 BTreeMap::new();
8700 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8701 by_workspace
8702 .entry(workspace_id)
8703 .or_default()
8704 .push((record_id, provider, updated_at_unix_ms));
8705 }
8706 let reason = if age_enabled && keep_enabled {
8707 "age+keep-n"
8708 } else if age_enabled {
8709 "age"
8710 } else {
8711 "keep-n"
8712 };
8713 let mut retired: u64 = 0;
8714 for (workspace_id, mut records) in by_workspace {
8715 records.sort_by(|left, right| right.2.cmp(&left.2));
8718 for (index, (record_id, provider, updated_at_unix_ms)) in
8719 records.into_iter().enumerate()
8720 {
8721 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8722 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8723 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8724 let should_retire = if age_enabled && keep_enabled {
8725 age_hit && keep_hit
8726 } else {
8727 age_hit || keep_hit
8728 };
8729 if !should_retire {
8730 continue;
8731 }
8732 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8733 Ok(()) => {
8734 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8735 retired += 1;
8736 tracing::info!(
8737 node_id = %self.node_id,
8738 record_id = %record_id,
8739 workspace_id = %workspace_id,
8740 provider = %provider,
8741 age_ms = record_age_ms,
8742 reason,
8743 "managed session record retired"
8744 );
8745 }
8746 Err(error) => tracing::warn!(
8747 node_id = %self.node_id,
8748 record_id = %record_id,
8749 workspace_id = %workspace_id,
8750 code = ?error.code,
8751 cause = %error.message,
8752 "session record retention sweep failed to retire a record"
8753 ),
8754 }
8755 }
8756 }
8757 if retired > 0 {
8758 let remaining_unavailable = self
8759 .session_records
8760 .lock()
8761 .unwrap_or_else(|poisoned| poisoned.into_inner())
8762 .records
8763 .values()
8764 .filter(|record| record.state == ManagedSessionState::Unavailable)
8765 .count();
8766 tracing::info!(
8767 node_id = %self.node_id,
8768 retired,
8769 remaining_unavailable,
8770 "session record retention sweep completed"
8771 );
8772 }
8773 }
8774
8775 async fn resume_session_record(
8776 &self,
8777 record_id: &SessionRecordId,
8778 terminal_size: gate4agent_types::TerminalSize,
8779 initial_prompt: Option<String>,
8780 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8781 let mut record = self.record(record_id)?;
8782 if record.mode == SessionMode::Acp
8785 && record.provider.as_str() == "claude"
8786 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8787 {
8788 record.mode = SessionMode::Pty;
8789 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8790 r.mode = SessionMode::Pty;
8791 }
8792 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8793 }
8794 if record.active_session.is_some()
8795 || matches!(
8796 record.state,
8797 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8798 )
8799 {
8800 return Err(failure(
8801 NodeFailureCode::SessionRecordBusy,
8802 "managed session already has a live runtime binding",
8803 ));
8804 }
8805 let identity = record.provider_session.clone().ok_or_else(|| {
8806 failure(
8807 NodeFailureCode::SessionRecordNotResumable,
8808 "managed session has no verified provider session identity",
8809 )
8810 })?;
8811 let working_directory = self.workspace_root(&record.workspace_id)?;
8812 if !platform::roots_equal(
8813 &working_directory,
8814 windows_path_text(&record.canonical_root),
8815 ) {
8816 return Err(failure(
8817 NodeFailureCode::SessionRecordConflict,
8818 "managed session workspace root changed; refusing to resume in another directory",
8819 ));
8820 }
8821 {
8822 let records = self
8823 .session_records
8824 .lock()
8825 .unwrap_or_else(|poisoned| poisoned.into_inner());
8826 if records.records.values().any(|candidate| {
8827 candidate.record_id != *record_id
8828 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8829 session_registry::same_provider_session(
8830 &candidate.provider,
8831 candidate_identity,
8832 &record.provider,
8833 &identity,
8834 )
8835 })
8836 && (candidate.active_session.is_some()
8837 || matches!(
8838 candidate.state,
8839 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8840 ))
8841 }) {
8842 return Err(failure(
8843 NodeFailureCode::SessionRecordConflict,
8844 "provider session is already active through another managed record",
8845 ));
8846 }
8847 }
8848 let runtime_requirement = if initial_prompt.is_some() {
8849 ProviderRuntimeRequirement::ResumeWithPrompt
8850 } else {
8851 ProviderRuntimeRequirement::Resume
8852 };
8853 let runtime_policy = self.admit_provider_runtime(
8854 &record.provider,
8855 runtime_requirement,
8856 )
8857 .await?;
8858 let working_directory = match self.m4a_accounts.as_ref() {
8860 Some(_) => {
8861 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8862 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8863 }
8864 None => working_directory,
8865 };
8866 let request = ResumeLaunchRequest {
8867 working_directory,
8868 terminal_size,
8869 initial_prompt,
8870 };
8871 request
8872 .validate()
8873 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8874 self.ensure_binding_capacity()?;
8875 let resolved_materialization = match self.resolve_record_materialization(
8876 record_id,
8877 &record.provider,
8878 record.mode,
8879 record.environment_profile.as_ref(),
8880 record.bundle.as_ref(),
8881 record.context.as_ref(),
8882 ) {
8883 Ok(materialization) => materialization,
8884 Err(error) => {
8885 let label = match error.code {
8886 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8887 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8888 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8889 _ => "environment-profile-unavailable",
8890 };
8891 self.mark_record_error(record_id, label)?;
8892 return Err(error);
8893 }
8894 };
8895 let (materialization_id, environment_overlay) = match resolved_materialization {
8896 Some((id, overlay)) => (Some(id), overlay),
8897 None => (None, None),
8898 };
8899 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8900 let environment_overlay = {
8902 let mut args: Vec<OsString> = Vec::new();
8903 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8904 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8905 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8906 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8907 }
8908 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8909 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8910 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8911 if let Some(sel) = options.as_ref() {
8912 args.push("--model".into()); args.push(sel.model.clone().into());
8913 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8914 args.push("--effort".into()); args.push(effort.to_owned().into());
8915 }
8916 }
8917 }
8918 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8919 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8920 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8921 instance_id: instance_id.0,
8922 provider: record.provider.as_str().to_owned(),
8923 workspace_id: record.workspace_id.as_str().to_owned(),
8924 provider_session_id: identity.id.clone(),
8925 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8926 cwd: String::new(),
8927 acp: record.mode == SessionMode::Acp,
8928 options,
8929 cols: 0,
8930 rows: 0,
8931 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8932 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8933 });
8934 }
8935 }
8936 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8937 };
8938 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8940 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8941 if let Some(old) = self.bound_address_for_record(record_id) {
8942 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8943 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8944 }
8945 }
8946 let session_key = self.m4a_session_key(instance_id);
8947 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8948 }
8949 _ => environment_overlay,
8950 };
8951 let mut environment_selection = match SpawnPreparer::select_environment(self,
8952 instance_id,
8953 &record.provider,
8954 record.mode,
8955 record.environment_profile.as_ref(),
8956 ) {
8957 Ok(selection) => selection,
8958 Err(error) => {
8959 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8960 return Err(error);
8961 }
8962 };
8963 let mut instance_overlay = environment_overlay
8964 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8965 .transpose()?
8966 .flatten();
8967 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8968 self.remove_session(&stale_address).await?;
8969 }
8970 let address = SessionAddress {
8971 workspace_id: record.workspace_id.clone(),
8972 session: SessionKey {
8973 instance_id,
8974 generation: SessionGeneration(1),
8975 },
8976 };
8977 let transaction = self
8978 .state_transaction
8979 .lock()
8980 .unwrap_or_else(|poisoned| poisoned.into_inner());
8981 let current = self.record(record_id)?;
8982 if current.active_session.is_some()
8983 || matches!(
8984 current.state,
8985 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8986 )
8987 {
8988 return Err(failure(
8989 NodeFailureCode::SessionRecordBusy,
8990 "managed session became active while resume was being prepared",
8991 ));
8992 }
8993 self.bind_managed_session_with_materialization(
8994 &address,
8995 record_id.clone(),
8996 runtime_policy,
8997 record.environment_profile.clone(),
8998 record.bundle.clone(),
8999 record.context.clone(),
9000 materialization_id,
9001 );
9002 if let Some(selection) = environment_selection.take() {
9003 selection.retain();
9004 }
9005 if let Some(overlay) = instance_overlay.take() {
9006 overlay.retain();
9007 }
9008 drop(transaction);
9009
9010 let transport = match record.mode {
9011 SessionMode::Pty => TransportKind::Pty,
9012 SessionMode::Inline => TransportKind::Pipe,
9013 SessionMode::Acp => TransportKind::Acp,
9014 };
9015 let agent_id = record.provider.clone();
9016 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9017 if let Err(error) = self
9018 .dispatch_bounded(
9019 ControlCommand::Register {
9020 instance_id,
9021 agent_id,
9022 transport,
9023 },
9024 dispatch_timeout,
9025 )
9026 .await
9027 {
9028 if let Some(binding) = self.remove_binding(&address) {
9029 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9030 }
9031 return Err(error);
9032 }
9033 let command = self.prepare_command(ControlCommand::Resume {
9034 instance_id,
9035 target: ResumeTarget::ProviderSession { identity },
9036 runtime_policy,
9037 request,
9038 });
9039 let started_after = self.current_sequence();
9040 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9041 self.rollback_spawn(&address, dispatch_timeout).await?;
9042 return Err(error);
9043 }
9044 if let Err(error) = self.dispatch_envelope(command) {
9045 self.clear_armed_resume(&address);
9046 self.rollback_spawn(&address, dispatch_timeout).await?;
9047 return Err(error);
9048 }
9049 match self
9050 .wait_for_record_resume(&address, started_after)
9051 .await
9052 {
9053 Ok(settled_address) => {
9054 let managed_lease_id = {
9055 self.managed_worktrees.lock()
9056 .unwrap_or_else(|poisoned| poisoned.into_inner())
9057 .lease_for_workspace(&record.workspace_id)
9058 };
9059 if let Some(lease_id) = managed_lease_id {
9060 {
9061 let mut bindings = self.session_bindings.lock()
9062 .unwrap_or_else(|poisoned| poisoned.into_inner());
9063 let binding = bindings.get_mut(&settled_address.session.instance_id)
9064 .ok_or_else(|| failure(
9065 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9066 "resumed managed session lost its exact binding",
9067 ))?;
9068 if binding.generation != settled_address.session.generation {
9069 return Err(failure(
9070 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071 "resumed managed session generation diverged",
9072 ));
9073 }
9074 binding.managed_worktree_lease_id = Some(lease_id.clone());
9075 }
9076 let snapshot = self.managed_worktrees.lock()
9077 .unwrap_or_else(|poisoned| poisoned.into_inner())
9078 .bind_session(
9079 &lease_id,
9080 ManagedWorktreeSessionHolder {
9081 incarnation_id: self.incarnation_id,
9082 instance_id: settled_address.session.instance_id,
9083 generation: settled_address.session.generation,
9084 },
9085 Some(record_id.clone()),
9086 unix_time_ms(),
9087 ).map_err(|message| failure(
9088 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089 &message,
9090 ))?;
9091 if let Err(error) = self.persist_state() {
9092 let _ = self.remove_session(&settled_address).await;
9093 return Err(persistence_failure(error));
9094 }
9095 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9096 }
9097 Ok((self.record(record_id)?, settled_address))
9098 }
9099 Err(error) => {
9100 if matches!(
9101 error.code,
9102 NodeFailureCode::SessionRecordNotResumable
9103 | NodeFailureCode::BackendOperationFailed
9104 ) {
9105 if let Some(bound) = self.bound_address_for_record(record_id) {
9106 let _ = self.remove_session(&bound).await;
9107 }
9108 }
9109 Err(error)
9110 }
9111 }
9112 }
9113
9114 async fn wait_for_record_resume(
9115 &self,
9116 initial_address: &SessionAddress,
9117 after_sequence: u64,
9118 ) -> Result<SessionAddress, NodeFailure> {
9119 let deadline = Instant::now()
9120 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9121 let mut scan_after = after_sequence;
9122 loop {
9123 let events = self
9124 .history
9125 .lock()
9126 .unwrap_or_else(|poisoned| poisoned.into_inner())
9127 .events
9128 .iter()
9129 .filter(|event| event.sequence > scan_after)
9130 .cloned()
9131 .collect::<Vec<_>>();
9132 for envelope in events {
9133 scan_after = scan_after.max(envelope.sequence);
9134 let NodeEvent::Control { address, event } = envelope.event else {
9135 continue;
9136 };
9137 if address.session.instance_id != initial_address.session.instance_id {
9138 continue;
9139 }
9140 match event.event {
9141 ControlEventKind::Resumed { .. } => return Ok(address),
9142 ControlEventKind::ResumeDenied { reason } => {
9143 return Err(failure(
9144 NodeFailureCode::SessionRecordNotResumable,
9145 &reason,
9146 ));
9147 }
9148 ControlEventKind::ResumeFailed { message }
9149 | ControlEventKind::CommandRejected { message } => {
9150 return Err(failure(
9151 NodeFailureCode::BackendOperationFailed,
9152 &message,
9153 ));
9154 }
9155 _ => {}
9156 }
9157 }
9158 if Instant::now() >= deadline {
9159 return Err(failure(
9160 NodeFailureCode::BackendBusy,
9161 "managed resume did not settle before the bounded deadline",
9162 ));
9163 }
9164 sleep(Duration::from_millis(5)).await;
9165 }
9166 }
9167
9168 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9169 self.workspaces
9170 .read()
9171 .unwrap_or_else(|poisoned| poisoned.into_inner())
9172 .get(workspace_id)
9173 .cloned()
9174 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9175 }
9176
9177 fn workspace_roots(&self) -> Vec<PathBuf> {
9178 self.workspaces
9179 .read()
9180 .unwrap_or_else(|poisoned| poisoned.into_inner())
9181 .values()
9182 .map(PathBuf::from)
9183 .collect()
9184 }
9185
9186 async fn inspect_workspace(
9187 &self,
9188 workspace_id: WorkspaceId,
9189 ) -> Result<WorkspaceInspection, NodeFailure> {
9190 let _permit = self
9191 .inspection_slots
9192 .clone()
9193 .try_acquire_owned()
9194 .map_err(|_| {
9195 tracing::warn!(
9196 workspace_id = %workspace_id,
9197 "workspace inspection rejected: inspection capacity is busy",
9198 );
9199 failure(
9200 NodeFailureCode::BackendBusy,
9201 "workspace inspection capacity is busy",
9202 )
9203 })?;
9204 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9205 tracing::warn!(
9206 workspace_id = %workspace_id,
9207 code = ?error.code,
9208 "workspace inspection rejected: workspace is not registered",
9209 );
9210 error
9211 })?;
9212 let tree_root = canonical_root.clone();
9213 let started_at = Instant::now();
9214 let time_budget_ms = workspace_inspection_time_budget_ms();
9215 let entry_cap = workspace_inspection_entry_cap();
9216 let deadline = started_at + Duration::from_millis(time_budget_ms);
9217 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9218 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9219 })
9220 .await
9221 .map_err(|error| {
9222 tracing::warn!(
9223 workspace_id = %workspace_id,
9224 error = %error,
9225 "workspace inspection rejected: walk task failed",
9226 );
9227 failure(
9228 NodeFailureCode::BackendOperationFailed,
9229 &format!("workspace inspection task failed: {error}"),
9230 )
9231 })?;
9232 let (mut git, git_time_budget_exceeded) =
9233 inspect_git_workspace(&canonical_root, deadline).await;
9234 if !git.worktrees.is_empty() {
9235 let registered = self
9236 .workspaces
9237 .read()
9238 .unwrap_or_else(|poisoned| poisoned.into_inner())
9239 .iter()
9240 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9241 .collect::<Vec<_>>();
9242 for worktree in &mut git.worktrees {
9243 worktree.workspace_id = registered
9244 .iter()
9245 .find(|(_, root)| {
9246 worktree_paths_equal(root, windows_path_text(&worktree.path))
9247 })
9248 .map(|(workspace_id, _)| workspace_id.clone());
9249 }
9250 }
9251 if git.is_repository {
9252 git.managed_worktree = self
9253 .managed_worktrees
9254 .lock()
9255 .unwrap_or_else(|poisoned| poisoned.into_inner())
9256 .git_scope(&workspace_id, git.branch.as_deref());
9257 }
9258 let truncation = workspace_inspection_truncation(
9259 &walk_budget,
9260 git_time_budget_exceeded,
9261 started_at.elapsed(),
9262 );
9263 if let Some(truncation) = &truncation {
9264 tracing::info!(
9265 workspace_id = %workspace_id,
9266 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9267 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9268 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9269 entries_visited = truncation.entries_visited,
9270 elapsed_ms = truncation.elapsed_ms,
9271 "workspace inspection truncated by its inner budget",
9272 );
9273 }
9274 Ok(WorkspaceInspection {
9275 workspace_id,
9276 entries,
9277 tree_truncated,
9278 git,
9279 truncation,
9280 })
9281 }
9282
9283 async fn browse_host_directories(
9284 &self,
9285 directory: Option<OpaqueHostPath>,
9286 after: Option<OpaqueHostPath>,
9287 ) -> Result<HostDirectoryListing, NodeFailure> {
9288 let permit = self
9289 .inspection_slots
9290 .clone()
9291 .try_acquire_owned()
9292 .map_err(|_| {
9293 failure(
9294 NodeFailureCode::BackendBusy,
9295 "host directory browse capacity is busy",
9296 )
9297 })?;
9298 let task = tokio::task::spawn_blocking(move || {
9299 let _permit = permit;
9300 browse_host_directories(directory, after)
9301 });
9302 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9303 .await
9304 .map_err(|_| {
9305 failure(
9306 NodeFailureCode::HostDirectoryReadTimedOut,
9307 "host directory browse exceeded its bounded deadline",
9308 )
9309 })?
9310 .map_err(|_| {
9311 failure(
9312 NodeFailureCode::HostDirectoryReadFailed,
9313 "host directory browse task failed",
9314 )
9315 })?
9316 .map_err(host_directory_failure)
9317 }
9318
9319 async fn read_workspace_file(
9320 &self,
9321 workspace_id: WorkspaceId,
9322 path: RepositoryPath,
9323 ) -> Result<WorkspaceFileRead, NodeFailure> {
9324 let canonical_root = self.workspace_root(&workspace_id)?;
9325 let permit = self
9326 .inspection_slots
9327 .clone()
9328 .try_acquire_owned()
9329 .map_err(|_| {
9330 failure(
9331 NodeFailureCode::BackendBusy,
9332 "workspace file read capacity is busy",
9333 )
9334 })?;
9335 let path_to_read = path.clone();
9336 let task = tokio::task::spawn_blocking(move || {
9337 let _permit = permit;
9338 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9339 });
9340 let content = timeout(
9341 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9342 task,
9343 )
9344 .await
9345 .map_err(|_| {
9346 failure(
9347 NodeFailureCode::RepositoryFileReadTimedOut,
9348 "repository file read exceeded its bounded deadline",
9349 )
9350 })?
9351 .map_err(|_| {
9352 failure(
9353 NodeFailureCode::RepositoryFileReadFailed,
9354 "repository file read task failed",
9355 )
9356 })?
9357 .map_err(workspace_file_failure)?;
9358 let content = match content {
9359 WorkspaceFileBytes::Utf8(text) => {
9360 let revision = Some(workspace_file_revision(text.as_bytes()));
9361 return Ok(WorkspaceFileRead {
9362 workspace_id,
9363 path,
9364 content: WorkspaceFileContent::Utf8 {
9365 byte_len: u32::try_from(text.len())
9366 .expect("bounded workspace text length must fit u32"),
9367 text,
9368 },
9369 revision,
9370 });
9371 }
9372 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9373 WorkspaceFileContent::NonUtf8 {
9374 byte_len: u32::try_from(byte_length)
9375 .expect("bounded workspace file length must fit u32"),
9376 }
9377 }
9378 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9379 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9380 },
9381 };
9382 Ok(WorkspaceFileRead {
9383 workspace_id,
9384 path,
9385 content,
9386 revision: None,
9387 })
9388 }
9389
9390 async fn write_workspace_file(
9391 &self,
9392 workspace_id: WorkspaceId,
9393 path: RepositoryPath,
9394 expected_revision: WorkspaceFileRevision,
9395 text: String,
9396 ) -> Result<WorkspaceFileRead, NodeFailure> {
9397 let canonical_root = self.workspace_root(&workspace_id)?;
9398 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9399 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9400 })?;
9401 let path_to_write = path.clone();
9402 let expected = expected_revision.as_str().to_owned();
9403 let text_to_write = text.clone();
9404 let task = tokio::task::spawn_blocking(move || {
9405 let _permit = permit;
9406 write_workspace_file_to_disk(
9407 Path::new(&canonical_root),
9408 &path_to_write,
9409 &expected,
9410 &text_to_write,
9411 )
9412 });
9413 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9414 .await
9415 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9416 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9417 .map_err(workspace_file_write_failure)?;
9418 Ok(WorkspaceFileRead {
9419 workspace_id,
9420 path,
9421 content: WorkspaceFileContent::Utf8 {
9422 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9423 text,
9424 },
9425 revision: Some(WorkspaceFileRevision::new(revision)
9426 .expect("workspace writer returns a valid SHA-256 revision")),
9427 })
9428 }
9429
9430 async fn create_workspace_file(
9431 &self,
9432 workspace_id: WorkspaceId,
9433 path: RepositoryPath,
9434 ) -> Result<WorkspaceFileRead, NodeFailure> {
9435 let canonical_root = self.workspace_root(&workspace_id)?;
9436 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9437 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9438 })?;
9439 let path_to_create = path.clone();
9440 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9441 let task_commit_state = Arc::clone(&commit_state);
9442 let task = tokio::task::spawn_blocking(move || {
9443 let _permit = permit;
9444 create_workspace_file_on_disk(
9445 Path::new(&canonical_root),
9446 &path_to_create,
9447 &task_commit_state,
9448 )
9449 });
9450 let revision = settle_workspace_entry_create(
9451 task,
9452 commit_state,
9453 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9454 "repository file create exceeded its bounded deadline",
9455 "repository file create task failed",
9456 )
9457 .await?;
9458 Ok(WorkspaceFileRead {
9459 workspace_id,
9460 path,
9461 content: WorkspaceFileContent::Utf8 {
9462 text: String::new(),
9463 byte_len: 0,
9464 },
9465 revision: Some(WorkspaceFileRevision::new(revision)
9466 .expect("workspace creator returns a valid SHA-256 revision")),
9467 })
9468 }
9469
9470 async fn create_workspace_directory(
9471 &self,
9472 workspace_id: WorkspaceId,
9473 path: RepositoryPath,
9474 ) -> Result<WorkspaceEntry, NodeFailure> {
9475 let canonical_root = self.workspace_root(&workspace_id)?;
9476 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9477 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9478 })?;
9479 let path_to_create = path.clone();
9480 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9481 let task_commit_state = Arc::clone(&commit_state);
9482 let task = tokio::task::spawn_blocking(move || {
9483 let _permit = permit;
9484 create_workspace_directory_on_disk(
9485 Path::new(&canonical_root),
9486 &path_to_create,
9487 &task_commit_state,
9488 )
9489 });
9490 settle_workspace_entry_create(
9491 task,
9492 commit_state,
9493 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9494 "repository directory create exceeded its bounded deadline",
9495 "repository directory create task failed",
9496 )
9497 .await?;
9498 Ok(WorkspaceEntry {
9499 relative_path: path,
9500 kind: WorkspaceEntryKind::Directory,
9501 })
9502 }
9503
9504 async fn read_git_history(
9505 &self,
9506 workspace_id: WorkspaceId,
9507 path: Option<RepositoryPath>,
9508 before: Option<GitObjectId>,
9509 limit: u16,
9510 ) -> Result<GitHistoryPage, NodeFailure> {
9511 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9512 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9513 })?;
9514 let root = self.workspace_root(&workspace_id)?;
9515 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9516 }
9517
9518 async fn read_git_diff(
9519 &self,
9520 workspace_id: WorkspaceId,
9521 request: GitDiffRequest,
9522 ) -> Result<GitDiff, NodeFailure> {
9523 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9524 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9525 })?;
9526 let root = self.workspace_root(&workspace_id)?;
9527 read_git_diff_bounded(&root, request).await
9528 }
9529
9530 fn native_session_route_root(
9531 &self,
9532 route: &NativeSessionCatalogRoute,
9533 ) -> Result<Option<String>, NodeFailure> {
9534 route
9535 .validate()
9536 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9537 match route.scope {
9538 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9539 .workspace_id
9540 .as_ref()
9541 .map(|workspace_id| self.workspace_root(workspace_id))
9542 .transpose(),
9543 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9544 }
9545 }
9546
9547 fn project_native_session_entries(
9548 &self,
9549 route: &NativeSessionCatalogRoute,
9550 entries: Vec<ScopedNativeSessionCatalogEntry>,
9551 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9552 let current_root = route
9553 .workspace_id
9554 .as_ref()
9555 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9556 let records = self
9557 .session_records
9558 .lock()
9559 .unwrap_or_else(|poisoned| poisoned.into_inner());
9560 let mut seen_records = std::collections::HashSet::new();
9561 entries
9562 .into_iter()
9563 .map(|entry| {
9564 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9565 let matches = records
9566 .records
9567 .values()
9568 .filter(|record| {
9569 record.provider == route.provider
9570 && record.workspace_id == *workspace_id
9571 && current_root.as_deref().is_some_and(|root| {
9572 platform::roots_equal(
9573 root,
9574 windows_path_text(&record.canonical_root),
9575 )
9576 })
9577 && record.provider_session.as_ref().is_some_and(|identity| {
9578 session_registry::same_provider_session(
9579 &record.provider,
9580 identity,
9581 &route.provider,
9582 &entry.provider_session,
9583 )
9584 })
9585 })
9586 .map(|record| record.record_id.clone())
9587 .collect::<Vec<_>>();
9588 (matches.len() == 1
9589 && seen_records.insert(matches[0].clone()))
9590 .then(|| matches[0].clone())
9591 });
9592 let metadata = entry.metadata;
9593 crate::protocol::NativeSessionCatalogEntry {
9594 selection_id: metadata.selection_id,
9595 title: metadata.title,
9596 modified_at_unix_ms: metadata.modified_at_unix_ms,
9597 model: metadata.model,
9598 message_count: metadata.message_count,
9599 completed_turn_count: metadata.completed_turn_count,
9600 external_group: entry.external_group,
9601 record_id,
9602 }
9603 })
9604 .collect()
9605 }
9606
9607 async fn catalog_native_sessions(
9608 &self,
9609 route: NativeSessionCatalogRoute,
9610 limit: u16,
9611 ) -> Result<(
9612 Vec<crate::protocol::NativeSessionCatalogEntry>,
9613 crate::protocol::NativeSessionCatalogSummary,
9614 ), NodeFailure> {
9615 let canonical_root = self.native_session_route_root(&route)?;
9616 let registered_workspace_roots = self.workspace_roots();
9617 let provider = route.provider.clone();
9618 let scope = route.scope;
9619 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9620 failure(
9621 NodeFailureCode::InvalidRequest,
9622 "native session catalog is not configured",
9623 )
9624 })?;
9625 let _permit = Arc::clone(&self.inspection_slots)
9626 .try_acquire_owned()
9627 .map_err(|_| {
9628 failure(
9629 NodeFailureCode::BackendBusy,
9630 "native session catalog capacity is busy",
9631 )
9632 })?;
9633 let page = run_native_session_catalog_operation(
9634 catalog,
9635 "native session catalog",
9636 move |catalog| {
9637 catalog.catalog_initial_for_scope(
9638 &provider,
9639 scope,
9640 canonical_root.as_deref().map(Path::new),
9641 ®istered_workspace_roots,
9642 limit,
9643 )
9644 },
9645 )
9646 .await?;
9647 let summary = crate::protocol::NativeSessionCatalogSummary {
9648 catalog_revision: page.revision,
9649 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9650 recent_total_count: page.recent_total_count,
9651 older_total_count: page.older_total_count,
9652 recent_next_after_selection_id: page.next_after_selection_id,
9653 recent_has_more: page.remaining_count > 0,
9654 };
9655 Ok((self.project_native_session_entries(&route, page.entries), summary))
9656 }
9657
9658 async fn page_native_sessions(
9659 &self,
9660 route: NativeSessionCatalogRoute,
9661 window: crate::protocol::NativeSessionCatalogWindow,
9662 catalog_revision: u64,
9663 recent_cutoff_unix_ms: u64,
9664 after_selection_id: Option<String>,
9665 limit: u16,
9666 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9667 let canonical_root = self.native_session_route_root(&route)?;
9668 let registered_workspace_roots = self.workspace_roots();
9669 let provider = route.provider.clone();
9670 let scope = route.scope;
9671 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9672 failure(
9673 NodeFailureCode::InvalidRequest,
9674 "native session catalog is not configured",
9675 )
9676 })?;
9677 let _permit = Arc::clone(&self.inspection_slots)
9678 .try_acquire_owned()
9679 .map_err(|_| {
9680 failure(
9681 NodeFailureCode::BackendBusy,
9682 "native session catalog capacity is busy",
9683 )
9684 })?;
9685 let page = run_native_session_catalog_operation(
9686 catalog,
9687 "native session catalog page",
9688 move |catalog| {
9689 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9690 &provider,
9691 scope,
9692 canonical_root.as_deref().map(Path::new),
9693 ®istered_workspace_roots,
9694 window,
9695 catalog_revision,
9696 recent_cutoff_unix_ms,
9697 after_selection_id.as_deref(),
9698 limit,
9699 ))
9700 },
9701 )
9702 .await?;
9703 let page = page.map_err(|error| match error {
9704 NativeSessionCatalogError::StaleCatalog => failure(
9705 NodeFailureCode::StaleNativeSessionCatalog,
9706 "native session catalog revision or cursor is stale",
9707 ),
9708 _ => failure(
9709 NodeFailureCode::BackendOperationFailed,
9710 "native session catalog page failed",
9711 ),
9712 })?;
9713 let entries = self.project_native_session_entries(&route, page.entries);
9714 Ok(crate::protocol::NativeSessionCatalogPage {
9715 window: page.window,
9716 revision: page.revision,
9717 entries,
9718 next_after_selection_id: page.next_after_selection_id,
9719 remaining_count: page.remaining_count,
9720 has_more: page.remaining_count > 0,
9721 })
9722 }
9723
9724 async fn preview_native_session(
9725 &self,
9726 selection: NativeSessionSelection,
9727 message_limit: u16,
9728 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9729 selection
9730 .validate()
9731 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9732 let canonical_root = self.native_session_route_root(&selection.route)?;
9733 let registered_workspace_roots = self.workspace_roots();
9734 let provider = selection.route.provider.clone();
9735 let scope = selection.route.scope;
9736 let catalog_revision = selection.catalog_revision;
9737 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9738 let selection_id = selection.selection_id.clone();
9739 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9740 failure(
9741 NodeFailureCode::InvalidRequest,
9742 "native session preview is not configured",
9743 )
9744 })?;
9745 let _permit = Arc::clone(&self.inspection_slots)
9746 .try_acquire_owned()
9747 .map_err(|_| {
9748 failure(
9749 NodeFailureCode::BackendBusy,
9750 "native session preview capacity is busy",
9751 )
9752 })?;
9753 let preview = run_native_session_catalog_operation(
9754 catalog,
9755 "native session preview",
9756 move |catalog| {
9757 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9758 &provider,
9759 scope,
9760 canonical_root.as_deref().map(Path::new),
9761 ®istered_workspace_roots,
9762 catalog_revision,
9763 recent_cutoff_unix_ms,
9764 &selection_id,
9765 message_limit,
9766 ))
9767 },
9768 )
9769 .await?;
9770 preview
9771 .map(Into::into)
9772 .map_err(native_session_preview_failure)
9773 }
9774
9775 async fn index_native_session(
9776 &self,
9777 selection: NativeSessionSelection,
9778 display_name: String,
9779 ) -> Result<ManagedSessionRecord, NodeFailure> {
9780 selection
9781 .validate()
9782 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9783 if selection.route.scope
9784 != gate4agent_types::NativeSessionCatalogScope::Workspace
9785 {
9786 return Err(failure(
9787 NodeFailureCode::WorkspaceRegistrationRequired,
9788 "register the external project as a workspace before indexing or resuming it",
9789 ));
9790 }
9791 let workspace_id = selection
9792 .route
9793 .workspace_id
9794 .clone()
9795 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9796 let canonical_root = self
9797 .native_session_route_root(&selection.route)?
9798 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9799 let registered_workspace_roots = self.workspace_roots();
9800 let provider = selection.route.provider.clone();
9801 let catalog_revision = selection.catalog_revision;
9802 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9803 let selection_id = selection.selection_id.clone();
9804 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9805 failure(
9806 NodeFailureCode::InvalidRequest,
9807 "native session index is not configured",
9808 )
9809 })?;
9810 let _permit = Arc::clone(&self.inspection_slots)
9811 .try_acquire_owned()
9812 .map_err(|_| {
9813 failure(
9814 NodeFailureCode::BackendBusy,
9815 "native session index capacity is busy",
9816 )
9817 })?;
9818 let resolved = run_native_session_catalog_operation(
9819 catalog,
9820 "native session index resolution",
9821 move |catalog| {
9822 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9823 &provider,
9824 gate4agent_types::NativeSessionCatalogScope::Workspace,
9825 Some(Path::new(&canonical_root)),
9826 ®istered_workspace_roots,
9827 catalog_revision,
9828 recent_cutoff_unix_ms,
9829 &selection_id,
9830 ))
9831 },
9832 )
9833 .await?
9834 .map_err(native_session_preview_failure)?;
9835 self.index_provider_session_with_policy(
9836 workspace_id,
9837 selection.route.provider,
9838 resolved.identity,
9839 display_name,
9840 true,
9841 )
9842 }
9843
9844 async fn preview_native_session_by_session_id(
9845 &self,
9846 workspace_id: WorkspaceId,
9847 provider: AgentId,
9848 session_id: String,
9849 message_limit: u16,
9850 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9851 let canonical_root = self.workspace_root(&workspace_id)?;
9852 let registered_workspace_roots = self.workspace_roots();
9853 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9854 failure(
9855 NodeFailureCode::InvalidRequest,
9856 "native session preview is not configured",
9857 )
9858 })?;
9859 let _permit = Arc::clone(&self.inspection_slots)
9860 .try_acquire_owned()
9861 .map_err(|_| {
9862 failure(
9863 NodeFailureCode::BackendBusy,
9864 "native session preview capacity is busy",
9865 )
9866 })?;
9867 run_native_session_catalog_operation(
9868 catalog,
9869 "native session preview",
9870 move |catalog| {
9871 catalog.preview_session_id_for_workspace(
9872 &provider,
9873 Path::new(&canonical_root),
9874 ®istered_workspace_roots,
9875 &session_id,
9876 message_limit,
9877 )
9878 },
9879 )
9880 .await
9881 }
9882
9883 async fn register_workspace(
9884 &self,
9885 workspace_id: WorkspaceId,
9886 root: String,
9887 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9888 validate_workspace_request_root(&root)?;
9889 let canonicalize_workspace_id = workspace_id.clone();
9890 let workspace = tokio::task::spawn_blocking(move || {
9891 WorkspaceConfig::new(canonicalize_workspace_id, root)
9892 })
9893 .await
9894 .map_err(|error| {
9895 failure(
9896 NodeFailureCode::BackendOperationFailed,
9897 &format!("workspace canonicalization task failed: {error}"),
9898 )
9899 })?
9900 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9901 let transaction = self
9902 .state_transaction
9903 .lock()
9904 .unwrap_or_else(|poisoned| poisoned.into_inner());
9905 let mut workspaces = self
9906 .workspaces
9907 .write()
9908 .unwrap_or_else(|poisoned| poisoned.into_inner());
9909 if workspaces.contains_key(&workspace_id) {
9910 return Err(failure(
9911 NodeFailureCode::DuplicateWorkspaceId,
9912 "workspace ID is already registered",
9913 ));
9914 }
9915 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9916 platform::roots_equal(existing_root, workspace.canonical_root())
9917 }) {
9918 return Err(failure(
9919 NodeFailureCode::DuplicateWorkspaceRoot,
9920 &format!("workspace root is already registered as '{existing_id}'"),
9921 ));
9922 }
9923 {
9924 let records = self
9925 .session_records
9926 .lock()
9927 .unwrap_or_else(|poisoned| poisoned.into_inner());
9928 if let Some(record) = records.records.values().find(|record| {
9929 record.workspace_id == workspace_id
9930 && !record
9931 .canonical_root
9932 .as_utf8()
9933 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9934 }) {
9935 return Err(failure(
9936 NodeFailureCode::SessionRecordConflict,
9937 &format!(
9938 "workspace ID is retained by session record '{}' at another root",
9939 record.record_id,
9940 ),
9941 ));
9942 }
9943 }
9944 let snapshot = WorkspaceSnapshot {
9945 workspace_id: workspace_id.clone(),
9946 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9947 sessions: Vec::new(),
9948 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9949 managed_worktree_profiles: Some(WorktreeProfileInventory {
9950 profiles: Vec::new(),
9951 }),
9952 };
9953 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9954 drop(workspaces);
9955 let previous_records = {
9956 let mut records = self
9957 .session_records
9958 .lock()
9959 .unwrap_or_else(|poisoned| poisoned.into_inner());
9960 let previous = records.records.clone();
9961 for record in records.records.values_mut().filter(|record| {
9962 record.workspace_id == workspace_id
9963 && record
9964 .canonical_root
9965 .as_utf8()
9966 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9967 }) {
9968 if record.active_session.is_none() {
9969 record.state = detached_record_state(record);
9970 }
9971 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9972 record.last_error = None;
9973 }
9974 record.updated_at_unix_ms = unix_time_ms();
9975 }
9976 previous
9977 };
9978 if let Err(error) = self.persist_state_locked() {
9979 self.workspaces
9980 .write()
9981 .unwrap_or_else(|poisoned| poisoned.into_inner())
9982 .remove(&workspace_id);
9983 self.session_records
9984 .lock()
9985 .unwrap_or_else(|poisoned| poisoned.into_inner())
9986 .records = previous_records;
9987 return Err(persistence_failure(error));
9988 }
9989 let restored = self
9990 .session_records
9991 .lock()
9992 .unwrap_or_else(|poisoned| poisoned.into_inner())
9993 .records
9994 .values()
9995 .filter(|record| record.workspace_id == workspace_id)
9996 .cloned()
9997 .collect::<Vec<_>>();
9998 drop(transaction);
9999 self.publish(NodeEvent::WorkspaceAdded {
10000 workspace: snapshot.clone(),
10001 });
10002 for record in restored {
10003 self.publish_record(record);
10004 }
10005 Ok(snapshot)
10006 }
10007
10008 async fn create_standalone_workspace(
10009 &self,
10010 workspace_id: WorkspaceId,
10011 root: String,
10012 initial_branch: Option<String>,
10013 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10014 validate_workspace_request_root(&root)?;
10015 validate_git_revision("initial branch", initial_branch.as_deref())?;
10016 let preflight_workspace_id = workspace_id.clone();
10017 let preflight_root = root.clone();
10018 let canonical_candidate = tokio::task::spawn_blocking(move || {
10019 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10020 })
10021 .await
10022 .map_err(|_| failure(
10023 NodeFailureCode::BackendOperationFailed,
10024 "standalone workspace root preflight task failed",
10025 ))?
10026 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10027 {
10028 let workspaces = self
10029 .workspaces
10030 .read()
10031 .unwrap_or_else(|poisoned| poisoned.into_inner());
10032 if workspaces.contains_key(&workspace_id) {
10033 return Err(failure(
10034 NodeFailureCode::DuplicateWorkspaceId,
10035 "workspace ID is already registered",
10036 ));
10037 }
10038 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10039 platform::roots_equal(existing_root, &canonical_candidate)
10040 }) {
10041 return Err(failure(
10042 NodeFailureCode::DuplicateWorkspaceRoot,
10043 &format!("workspace root is already registered as '{existing_id}'"),
10044 ));
10045 }
10046 }
10047 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10048 .await
10049 .map_err(standalone_workspace_failure)?;
10050 prepared
10051 .verify_for_registration()
10052 .await
10053 .map_err(standalone_workspace_failure)?;
10054 match self
10055 .register_workspace(workspace_id, prepared.root().to_owned())
10056 .await
10057 {
10058 Ok(workspace) => Ok(workspace),
10059 Err(registration_error) => {
10060 if prepared.compensate_registration_failure().await {
10061 Err(registration_error)
10062 } else {
10063 Err(failure(
10064 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10065 "standalone workspace initialization succeeded but registration failed; recovery is required",
10066 ))
10067 }
10068 }
10069 }
10070 }
10071
10072 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10073 let bindings = self
10074 .session_bindings
10075 .lock()
10076 .unwrap_or_else(|poisoned| poisoned.into_inner());
10077 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10078 return Err(failure(
10079 NodeFailureCode::WorkspaceBusy,
10080 "workspace retains a bound session; remove every session before unregistering it",
10081 ));
10082 }
10083 drop(bindings);
10084 let transaction = self
10085 .state_transaction
10086 .lock()
10087 .unwrap_or_else(|poisoned| poisoned.into_inner());
10088 let mut workspaces = self
10089 .workspaces
10090 .write()
10091 .unwrap_or_else(|poisoned| poisoned.into_inner());
10092 if !workspaces.contains_key(workspace_id) {
10093 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10094 }
10095 if workspaces.len() == 1 {
10096 return Err(failure(
10097 NodeFailureCode::LastWorkspace,
10098 "node must retain at least one workspace",
10099 ));
10100 }
10101 let removed_root = workspaces
10102 .remove(workspace_id)
10103 .expect("workspace presence was validated");
10104 drop(workspaces);
10105 let previous_records = {
10106 let mut records = self
10107 .session_records
10108 .lock()
10109 .unwrap_or_else(|poisoned| poisoned.into_inner());
10110 let previous = records.records.clone();
10111 for record in records
10112 .records
10113 .values_mut()
10114 .filter(|record| &record.workspace_id == workspace_id)
10115 {
10116 record.active_session = None;
10117 record.state = ManagedSessionState::Unavailable;
10118 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10119 record.updated_at_unix_ms = unix_time_ms();
10120 }
10121 previous
10122 };
10123 if let Err(error) = self.persist_state_locked() {
10124 self.workspaces
10125 .write()
10126 .unwrap_or_else(|poisoned| poisoned.into_inner())
10127 .insert(workspace_id.clone(), removed_root);
10128 self.session_records
10129 .lock()
10130 .unwrap_or_else(|poisoned| poisoned.into_inner())
10131 .records = previous_records;
10132 return Err(persistence_failure(error));
10133 }
10134 let unavailable = self
10135 .session_records
10136 .lock()
10137 .unwrap_or_else(|poisoned| poisoned.into_inner())
10138 .records
10139 .values()
10140 .filter(|record| &record.workspace_id == workspace_id)
10141 .cloned()
10142 .collect::<Vec<_>>();
10143 drop(transaction);
10144 self.publish(NodeEvent::WorkspaceRemoved {
10145 workspace_id: workspace_id.clone(),
10146 });
10147 for record in unavailable {
10148 self.publish_record(record);
10149 }
10150 Ok(())
10151 }
10152
10153 async fn create_worktree(
10154 &self,
10155 source_workspace_id: WorkspaceId,
10156 workspace_id: WorkspaceId,
10157 target_root: String,
10158 branch: String,
10159 base: Option<String>,
10160 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10161 self.require_worktree_service(&source_workspace_id)?;
10162 validate_workspace_request_root(&target_root)?;
10163 validate_git_revision("worktree base", base.as_deref())?;
10164 let source_root = self.workspace_root(&source_workspace_id)?;
10165 {
10166 let workspaces = self
10167 .workspaces
10168 .read()
10169 .unwrap_or_else(|poisoned| poisoned.into_inner());
10170 if workspaces.contains_key(&workspace_id) {
10171 return Err(failure(
10172 NodeFailureCode::DuplicateWorkspaceId,
10173 "workspace ID is already registered",
10174 ));
10175 }
10176 if let Some((existing_id, _)) = workspaces
10177 .iter()
10178 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10179 {
10180 return Err(failure(
10181 NodeFailureCode::DuplicateWorkspaceRoot,
10182 &format!("workspace root is already registered as '{existing_id}'"),
10183 ));
10184 }
10185 }
10186 let mut worktree = create_git_worktree(
10187 &source_root,
10188 &target_root,
10189 &branch,
10190 base.as_deref(),
10191 )
10192 .await
10193 .map_err(git_worktree_failure)?;
10194 let workspace = match self
10195 .register_workspace(workspace_id, worktree.path.clone())
10196 .await
10197 {
10198 Ok(workspace) => workspace,
10199 Err(registration_error) => {
10200 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10201 return Err(failure(
10202 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10203 "worktree registration failed and compensation requires recovery",
10204 ));
10205 }
10206 return Err(failure(
10207 registration_error.code,
10208 ®istration_error.message,
10209 ));
10210 }
10211 };
10212 worktree.workspace_id = Some(workspace.workspace_id.clone());
10213 Ok((protocol_worktree(worktree), workspace))
10214 }
10215
10216 async fn remove_worktree(
10217 &self,
10218 source_workspace_id: WorkspaceId,
10219 target_root: String,
10220 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10221 self.require_worktree_service(&source_workspace_id)?;
10222 validate_workspace_request_root(&target_root)?;
10223 let source_root = self.workspace_root(&source_workspace_id)?;
10224 let target_root = normalize_worktree_removal_target(&target_root)
10225 .map_err(git_worktree_failure)?;
10226 let target = list_git_worktrees(&source_root)
10227 .await
10228 .map_err(git_worktree_failure)?
10229 .into_iter()
10230 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10231 .ok_or_else(|| {
10232 failure(
10233 NodeFailureCode::WorktreeProtected,
10234 "refusing to remove a path that is not in Git's worktree listing",
10235 )
10236 })?;
10237 let registered_workspace_id = {
10238 let workspaces = self
10239 .workspaces
10240 .read()
10241 .unwrap_or_else(|poisoned| poisoned.into_inner());
10242 let matched = workspaces
10243 .iter()
10244 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10245 .map(|(workspace_id, _)| workspace_id.clone());
10246 if matched.is_some() && workspaces.len() == 1 {
10247 return Err(failure(
10248 NodeFailureCode::LastWorkspace,
10249 "node must retain at least one workspace",
10250 ));
10251 }
10252 matched
10253 };
10254 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255 let bindings = self
10256 .session_bindings
10257 .lock()
10258 .unwrap_or_else(|poisoned| poisoned.into_inner());
10259 if bindings
10260 .values()
10261 .any(|binding| &binding.workspace_id == workspace_id)
10262 {
10263 return Err(failure(
10264 NodeFailureCode::WorkspaceBusy,
10265 "worktree workspace retains a bound session; remove every session first",
10266 ));
10267 }
10268 }
10269 remove_git_worktree(&source_root, &target.path)
10270 .await
10271 .map_err(git_worktree_failure)?;
10272 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10273 self.unregister_workspace(workspace_id)
10274 .map_err(|error| failure(error.code, &error.message))?;
10275 }
10276 Ok(registered_workspace_id)
10277 }
10278
10279 async fn cleanup_managed_worktree(
10280 &self,
10281 lease_id: &ManagedWorktreeLeaseId,
10282 explicit: bool,
10283 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10284 let lease = self.managed_worktrees.lock()
10285 .unwrap_or_else(|poisoned| poisoned.into_inner())
10286 .get(lease_id)
10287 .cloned()
10288 .ok_or_else(|| failure(
10289 NodeFailureCode::UnknownManagedWorktreeLease,
10290 "managed worktree lease does not exist",
10291 ))?;
10292 if lease.state == ManagedWorktreeLeaseState::Removed {
10293 return Ok(lease.snapshot());
10294 }
10295 if lease.has_holders() {
10296 return Err(failure(
10297 NodeFailureCode::ManagedWorktreeBusy,
10298 "managed worktree lease still has a session or durable-record holder",
10299 ));
10300 }
10301 if self
10302 .materializations
10303 .lock()
10304 .unwrap_or_else(|poisoned| poisoned.into_inner())
10305 .values()
10306 .any(|record| record.managed_lease_id() == Some(lease_id))
10307 {
10308 return Err(failure(
10309 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10310 "managed worktree cleanup is blocked by session environment ownership",
10311 ));
10312 }
10313 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10314 let snapshot = {
10315 let mut registry = self.managed_worktrees.lock()
10316 .unwrap_or_else(|poisoned| poisoned.into_inner());
10317 let current = registry.get_mut(lease_id)
10318 .expect("managed worktree lease remains present");
10319 current.state = ManagedWorktreeLeaseState::Retained;
10320 current.cleanup_failure = None;
10321 current.updated_at_unix_ms = unix_time_ms();
10322 current.snapshot()
10323 };
10324 if self.persist_state().is_err() {
10325 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10326 }
10327 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10328 return Ok(snapshot);
10329 }
10330 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10331 if profile.revision() != &lease.profile_revision {
10332 self.mark_managed_recovery_required(
10333 lease_id,
10334 ManagedWorktreeCleanupFailure::OwnershipConflict,
10335 );
10336 return Err(failure(
10337 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10338 "managed worktree profile revision changed",
10339 ));
10340 }
10341 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10342 self.mark_managed_recovery_required(
10343 lease_id,
10344 ManagedWorktreeCleanupFailure::OwnershipConflict,
10345 );
10346 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10347 })?;
10348 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10349 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10350 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10351 managed_git_worktree_failure(error)
10352 })?;
10353 let target = listed.iter()
10354 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10355 if let Some(target) = target {
10356 if target.is_main || target.is_bare || !exact_owned_worktree(
10357 &lease,
10358 &target.path,
10359 target.branch.as_deref(),
10360 &target.head,
10361 ) {
10362 self.mark_managed_recovery_required(
10363 lease_id,
10364 ManagedWorktreeCleanupFailure::OwnershipConflict,
10365 );
10366 return Err(failure(
10367 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10368 "Git worktree identity no longer matches the managed lease",
10369 ));
10370 }
10371 if target.prunable {
10372 self.mark_managed_cleanup_blocked(
10373 lease_id,
10374 ManagedWorktreeCleanupFailure::Prunable,
10375 );
10376 return Err(failure(
10377 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10378 "managed worktree is prunable and requires recovery",
10379 ));
10380 }
10381 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10382 let failure_kind = match error.kind {
10383 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10384 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10385 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10386 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10387 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10388 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10389 };
10390 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10391 self.mark_managed_recovery_required(lease_id, failure_kind);
10392 } else {
10393 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10394 }
10395 return Err(managed_git_worktree_failure(error));
10396 }
10397 } else {
10398 match std::fs::symlink_metadata(&lease.target_root) {
10399 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10400 Ok(_) => {
10401 self.mark_managed_recovery_required(
10402 lease_id,
10403 ManagedWorktreeCleanupFailure::OwnershipConflict,
10404 );
10405 return Err(failure(
10406 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10407 "managed target exists but is not owned by Git",
10408 ));
10409 }
10410 Err(_) => {
10411 self.mark_managed_cleanup_blocked(
10412 lease_id,
10413 ManagedWorktreeCleanupFailure::Backend,
10414 );
10415 return Err(failure(
10416 NodeFailureCode::BackendOperationFailed,
10417 "managed target identity could not be inspected",
10418 ));
10419 }
10420 }
10421 }
10422 let registered = self.workspaces.read()
10423 .unwrap_or_else(|poisoned| poisoned.into_inner())
10424 .contains_key(&lease.workspace_id);
10425 if registered {
10426 self.unregister_workspace(&lease.workspace_id)?;
10427 }
10428 let removed = {
10429 self.managed_worktrees.lock()
10430 .unwrap_or_else(|poisoned| poisoned.into_inner())
10431 .tombstone(lease_id, unix_time_ms())
10432 .expect("managed worktree lease remains present during cleanup")
10433 };
10434 self.persist_state().map_err(persistence_failure)?;
10435 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10436 Ok(removed.snapshot())
10437 }
10438
10439 fn mark_managed_cleanup_blocked(
10440 &self,
10441 lease_id: &ManagedWorktreeLeaseId,
10442 failure_kind: ManagedWorktreeCleanupFailure,
10443 ) {
10444 let snapshot = {
10445 let mut registry = self.managed_worktrees.lock()
10446 .unwrap_or_else(|poisoned| poisoned.into_inner());
10447 let Some(lease) = registry.get_mut(lease_id) else { return };
10448 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10449 lease.cleanup_failure = Some(failure_kind);
10450 lease.updated_at_unix_ms = unix_time_ms();
10451 lease.snapshot()
10452 };
10453 let _ = self.persist_state();
10454 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10455 }
10456
10457 fn require_worktree_service(
10458 &self,
10459 source_workspace_id: &WorkspaceId,
10460 ) -> Result<(), NodeFailure> {
10461 if self.managed_worktrees.lock()
10462 .unwrap_or_else(|poisoned| poisoned.into_inner())
10463 .lease_for_workspace(source_workspace_id)
10464 .is_some()
10465 {
10466 return Err(failure(
10467 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10468 "managed worktree targets are internal-only workspaces",
10469 ));
10470 }
10471 match self
10472 .worktree_service_modes
10473 .get(source_workspace_id)
10474 .copied()
10475 .unwrap_or(WorktreeServiceMode::Manual)
10476 {
10477 WorktreeServiceMode::Manual => Ok(()),
10478 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10479 NodeFailureCode::UnsupportedSpawnCapability,
10480 "Git worktree service mode is unavailable in this node build",
10481 )),
10482 }
10483 }
10484
10485 fn managed_reservation_conflict(
10486 &self,
10487 workspace_id: Option<&WorkspaceId>,
10488 root: Option<&str>,
10489 ) -> bool {
10490 self.managed_worktrees.lock()
10491 .unwrap_or_else(|poisoned| poisoned.into_inner())
10492 .active_records()
10493 .any(|lease| {
10494 workspace_id.is_some_and(|workspace_id| {
10495 workspace_id == &lease.source_workspace_id
10496 || workspace_id == &lease.workspace_id
10497 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10498 })
10499 }
10500
10501 fn reject_managed_reservation(
10502 &self,
10503 workspace_id: Option<&WorkspaceId>,
10504 root: Option<&str>,
10505 ) -> Result<(), NodeFailure> {
10506 if self.managed_reservation_conflict(workspace_id, root) {
10507 Err(failure(
10508 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10509 "managed worktree ownership may only be mutated through its lease cleanup",
10510 ))
10511 } else {
10512 Ok(())
10513 }
10514 }
10515
10516 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10517 self.validate_address(address)?;
10518 self.workspace_root(&address.workspace_id)
10519 }
10520
10521 fn require_session_runtime_policy(
10555 &self,
10556 address: &SessionAddress,
10557 requirement: ProviderRuntimeRequirement,
10558 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10559 let runtime_policy = {
10560 let bindings = self
10561 .session_bindings
10562 .lock()
10563 .unwrap_or_else(|poisoned| poisoned.into_inner());
10564 let binding = bindings
10565 .get(&address.session.instance_id)
10566 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10567 if binding.workspace_id != address.workspace_id {
10568 return Err(failure(
10569 NodeFailureCode::SessionWorkspaceMismatch,
10570 "session belongs to another workspace",
10571 ));
10572 }
10573 if binding.generation != address.session.generation {
10574 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10575 }
10576 binding.runtime_policy
10577 };
10578 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10579 let snapshot = self.internal_session_snapshot(address)?;
10580 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10581 && snapshot.provider.lead_activity != ProviderActivity::Idle
10582 {
10583 return Err(failure(
10584 NodeFailureCode::TurnInFlight,
10585 "session already has a turn in flight; wait for it to complete before sending another prompt",
10586 ));
10587 }
10588 }
10589 require_policy(runtime_policy, false, false, requirement)
10590 .map(|()| runtime_policy)
10591 .map_err(|_| failure(
10592 NodeFailureCode::UnsupportedCapability,
10593 "session runtime policy does not admit this semantic operation",
10594 ))
10595 }
10596
10597 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10598 let mut bindings = self
10599 .session_bindings
10600 .lock()
10601 .unwrap_or_else(|poisoned| poisoned.into_inner());
10602 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10603 binding.workspace_id == address.workspace_id
10604 && binding.generation == address.session.generation
10605 }) {
10606 let removed = bindings.remove(&address.session.instance_id);
10607 self.clear_terminal_frame_watermark(address);
10608 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10609 return removed;
10610 }
10611 None
10612 }
10613
10614 fn cleanup_session_owned_materialization(
10615 &self,
10616 address: &SessionAddress,
10617 binding: &SessionBinding,
10618 ) -> Result<(), NodeFailure> {
10619 let Some(id) = binding.materialization_id.as_ref() else {
10620 return Ok(());
10621 };
10622 let owner = self
10623 .materializations
10624 .lock()
10625 .unwrap_or_else(|poisoned| poisoned.into_inner())
10626 .get(id)
10627 .map(|record| record.owner().clone());
10628 match owner {
10629 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10630 Some(MaterializationOwner::Session {
10631 incarnation_id,
10632 instance_id,
10633 generation,
10634 }) if incarnation_id == self.incarnation_id
10635 && instance_id == address.session.instance_id
10636 && generation == address.session.generation =>
10637 {
10638 self.cleanup_materialization(id)
10639 }
10640 Some(MaterializationOwner::Session { .. }) => Err(failure(
10641 NodeFailureCode::BackendOperationFailed,
10642 "session environment ownership requires recovery",
10643 )),
10644 }
10645 }
10646
10647 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10648 let mut watermarks = self
10649 .terminal_frame_watermarks
10650 .lock()
10651 .unwrap_or_else(|poisoned| poisoned.into_inner());
10652 if watermarks
10653 .get(&address.session.instance_id)
10654 .is_some_and(|(current, _)| current == address)
10655 {
10656 watermarks.remove(&address.session.instance_id);
10657 }
10658 }
10659
10660 #[cfg(test)]
10661 fn address_for(
10662 &self,
10663 instance_id: AgentInstanceId,
10664 generation: SessionGeneration,
10665 ) -> Option<SessionAddress> {
10666 let binding = self.session_bindings
10667 .lock()
10668 .unwrap_or_else(|poisoned| poisoned.into_inner())
10669 .get(&instance_id)
10670 .cloned()?;
10671 (binding.generation == generation).then_some(SessionAddress {
10672 workspace_id: binding.workspace_id,
10673 session: SessionKey { instance_id, generation },
10674 })
10675 }
10676
10677 fn arm_resume(
10678 &self,
10679 address: &SessionAddress,
10680 command_id: CommandId,
10681 runtime_policy: ProviderRuntimePolicy,
10682 ) -> Result<(), NodeFailure> {
10683 let mut bindings = self
10684 .session_bindings
10685 .lock()
10686 .unwrap_or_else(|poisoned| poisoned.into_inner());
10687 let binding = bindings
10688 .get_mut(&address.session.instance_id)
10689 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10690 if binding.workspace_id != address.workspace_id {
10691 return Err(failure(
10692 NodeFailureCode::SessionWorkspaceMismatch,
10693 "session belongs to another workspace",
10694 ));
10695 }
10696 if binding.generation != address.session.generation {
10697 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10698 }
10699 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10700 Ok(())
10701 }
10702
10703 fn clear_armed_resume(&self, address: &SessionAddress) {
10704 let mut bindings = self
10705 .session_bindings
10706 .lock()
10707 .unwrap_or_else(|poisoned| poisoned.into_inner());
10708 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10709 if binding.workspace_id == address.workspace_id
10710 && binding.generation == address.session.generation
10711 {
10712 binding.pending_resume = None;
10713 }
10714 }
10715 }
10716
10717 fn publish_control(&self, event: ControlEvent) {
10718 let revokes_harness_mcp = matches!(
10719 &event.event,
10720 ControlEventKind::Exited { .. }
10721 | ControlEventKind::Failed { .. }
10722 | ControlEventKind::Removed
10723 );
10724 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10725 let mut bindings = self
10726 .session_bindings
10727 .lock()
10728 .unwrap_or_else(|poisoned| poisoned.into_inner());
10729 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10730 return;
10731 };
10732 let previous_address = SessionAddress {
10733 workspace_id: binding.workspace_id.clone(),
10734 session: SessionKey {
10735 instance_id: event.instance_id,
10736 generation: binding.generation,
10737 },
10738 };
10739 let mut resume_command_rejected = false;
10740 if binding.generation == event.generation {
10741 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10742 let rejected_resume = matches!(
10743 &event.event,
10744 ControlEventKind::CommandRejected { .. }
10745 ) && event.command_id == Some(pending_command_id);
10746 let resume_failed = matches!(
10747 &event.event,
10748 ControlEventKind::ResumeDenied { .. }
10749 | ControlEventKind::ResumeFailed { .. }
10750 );
10751 if pending_generation == binding.generation
10752 && (rejected_resume || resume_failed)
10753 {
10754 resume_command_rejected = rejected_resume;
10755 binding.pending_resume = None;
10756 }
10757 }
10758 } else {
10759 let expected = binding.generation.0.checked_add(1);
10760 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10761 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10762 generation == binding.generation
10763 })
10764 || expected != Some(event.generation.0)
10765 || !authorized
10766 {
10767 return;
10768 }
10769 let (_, _, runtime_policy) = binding
10770 .pending_resume
10771 .expect("authorized resume retains its pending runtime policy");
10772 binding.generation = event.generation;
10773 binding.runtime_policy = runtime_policy;
10774 binding.pending_resume = None;
10775 }
10776 (
10777 SessionAddress {
10778 workspace_id: binding.workspace_id.clone(),
10779 session: SessionKey {
10780 instance_id: event.instance_id,
10781 generation: event.generation,
10782 },
10783 },
10784 (previous_address.session.generation != event.generation)
10785 .then_some(previous_address),
10786 binding.record_id.clone(),
10787 binding.runtime_policy.provider_session_identity,
10788 resume_command_rejected,
10789 )
10790 };
10791 if let Some(previous_address) = previous_address.as_ref() {
10792 self.clear_terminal_frame_watermark(previous_address);
10793 }
10794 if let Some(record_id) = record_id {
10795 self.reconcile_managed_record(
10796 &record_id,
10797 &address,
10798 &event,
10799 identity_admitted,
10800 resume_command_rejected,
10801 );
10802 }
10803 match &event.event {
10811 ControlEventKind::Running { process_id } => {
10812 #[cfg(unix)]
10813 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10814 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10815 }
10816 tracing::info!(
10817 node_id = %self.node_id,
10818 workspace_id = %address.workspace_id,
10819 session = ?address.session,
10820 pid = ?process_id,
10821 "session process is running",
10822 );
10823 }
10824 ControlEventKind::Exited { exit_code, forced } => {
10825 #[cfg(unix)]
10826 if let Some(proxy) = self.signer_proxy.get().cloned() {
10827 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10828 if proxy.has_session(&key) {
10829 std::thread::spawn(move || proxy.release(&key));
10831 }
10832 }
10833 tracing::info!(
10834 node_id = %self.node_id,
10835 workspace_id = %address.workspace_id,
10836 session = ?address.session,
10837 exit_code = ?exit_code,
10838 forced = forced,
10839 "session process exited",
10840 );
10841 }
10842 ControlEventKind::Failed { message } => {
10843 tracing::warn!(
10844 node_id = %self.node_id,
10845 workspace_id = %address.workspace_id,
10846 session = ?address.session,
10847 cause = %message,
10848 "session failed",
10849 );
10850 }
10851 ControlEventKind::CommandRejected { message } => {
10852 tracing::warn!(
10853 node_id = %self.node_id,
10854 workspace_id = %address.workspace_id,
10855 session = ?address.session,
10856 cause = %message,
10857 "session command rejected",
10858 );
10859 }
10860 ControlEventKind::Removed => {
10861 tracing::info!(
10862 node_id = %self.node_id,
10863 workspace_id = %address.workspace_id,
10864 session = ?address.session,
10865 "session removed",
10866 );
10867 }
10868 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10869 tracing::info!(
10870 node_id = %self.node_id,
10871 workspace_id = %address.workspace_id,
10872 session = ?address.session,
10873 provider_session = ?provider_session,
10874 "session resume authorized",
10875 );
10876 }
10877 ControlEventKind::Resumed { session: provider_session, process_id } => {
10878 tracing::info!(
10879 node_id = %self.node_id,
10880 workspace_id = %address.workspace_id,
10881 session = ?address.session,
10882 provider_session = ?provider_session,
10883 pid = ?process_id,
10884 "session resumed",
10885 );
10886 }
10887 ControlEventKind::ResumeDenied { reason } => {
10888 tracing::warn!(
10889 node_id = %self.node_id,
10890 workspace_id = %address.workspace_id,
10891 session = ?address.session,
10892 cause = %reason,
10893 "session resume denied",
10894 );
10895 }
10896 ControlEventKind::ResumeFailed { message } => {
10897 tracing::warn!(
10898 node_id = %self.node_id,
10899 workspace_id = %address.workspace_id,
10900 session = ?address.session,
10901 cause = %message,
10902 "session resume failed",
10903 );
10904 }
10905 _ => {}
10906 }
10907 self.apply_pending_acp_options(&address, &event);
10908 self.apply_acp_auto_permit(&address, &event);
10909 let stream_chunk = agent_stream_chunk(&event);
10910 self.publish(NodeEvent::Control {
10911 address: address.clone(),
10912 event,
10913 });
10914 if let Some(chunk) = stream_chunk {
10915 self.publish_agent_stream_chunk(address.clone(), chunk);
10916 }
10917 if revokes_harness_mcp {
10918 SessionLifecycleHook::on_session_ended(self, &address);
10919 }
10920 }
10921
10922 fn reconcile_managed_record(
10923 &self,
10924 record_id: &SessionRecordId,
10925 address: &SessionAddress,
10926 event: &ControlEvent,
10927 identity_admitted: bool,
10928 resume_command_rejected: bool,
10929 ) {
10930 let _transaction = self
10931 .state_transaction
10932 .lock()
10933 .unwrap_or_else(|poisoned| poisoned.into_inner());
10934 let observed_identity = match &event.event {
10935 ControlEventKind::ProviderEvent {
10936 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10937 ..
10938 } if identity_admitted => Some(identity.clone()),
10939 _ => None,
10940 };
10941 let identity_was_observed = observed_identity.is_some();
10942 let replacement_id = observed_identity
10943 .as_ref()
10944 .and_then(|_| self.allocate_record_id().ok());
10945 let mut upserts = Vec::new();
10946 let mut removals = Vec::new();
10947 let mut rebind = None;
10948 let original_records;
10949 let original_materializations = self
10950 .materializations
10951 .lock()
10952 .unwrap_or_else(|poisoned| poisoned.into_inner())
10953 .clone();
10954 {
10955 let mut records = self
10956 .session_records
10957 .lock()
10958 .unwrap_or_else(|poisoned| poisoned.into_inner());
10959 original_records = records.records.clone();
10960 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10961 return;
10962 };
10963 if let Some(identity) = observed_identity {
10964 let existing_id = records
10965 .records
10966 .values()
10967 .find(|record| {
10968 record.record_id != *record_id
10969 && record.provider_session.as_ref().is_some_and(|record_identity| {
10970 session_registry::same_provider_session(
10971 &record.provider,
10972 record_identity,
10973 ¤t_snapshot.provider,
10974 &identity,
10975 )
10976 })
10977 })
10978 .map(|record| record.record_id.clone());
10979 if let Some(existing_id) = existing_id {
10980 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10981 record.mode == current_snapshot.mode
10982 && record.workspace_id == current_snapshot.workspace_id
10983 && record.environment_profile == current_snapshot.environment_profile
10984 && record.bundle == current_snapshot.bundle
10985 && record.context_id == current_snapshot.context_id
10986 && record.context == current_snapshot.context
10987 && record
10988 .canonical_root
10989 .as_utf8()
10990 .zip(current_snapshot.canonical_root.as_utf8())
10991 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10992 });
10993 if !scope_matches {
10994 if let Some(current) = records.records.get_mut(record_id) {
10995 current.state = ManagedSessionState::Unavailable;
10996 current.last_error =
10997 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10998 current.updated_at_unix_ms = unix_time_ms();
10999 upserts.push(current.clone());
11000 }
11001 } else {
11002 let existing_is_active = records
11003 .records
11004 .get(&existing_id)
11005 .is_some_and(|record| record.active_session.is_some());
11006 if existing_is_active {
11007 if let Some(current) = records.records.get_mut(record_id) {
11008 current.state = ManagedSessionState::Unavailable;
11009 current.last_error =
11010 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11011 current.updated_at_unix_ms = unix_time_ms();
11012 upserts.push(current.clone());
11013 }
11014 } else {
11015 if current_snapshot.provider_session.is_some() {
11016 if let Some(current) = records.records.get_mut(record_id) {
11017 current.active_session = None;
11018 current.state = ManagedSessionState::Dormant;
11019 current.updated_at_unix_ms = unix_time_ms();
11020 upserts.push(current.clone());
11021 }
11022 } else {
11023 records.records.remove(record_id);
11024 removals.push(record_id.clone());
11025 }
11026 if let Some(existing) = records.records.get_mut(&existing_id) {
11027 existing.provider_session = Some(identity.clone());
11028 existing.active_session = Some(address.clone());
11029 existing.state = ManagedSessionState::Live;
11030 existing.updated_at_unix_ms = unix_time_ms();
11031 existing.last_error = None;
11032 upserts.push(existing.clone());
11033 }
11034 rebind = Some(existing_id);
11035 }
11036 }
11037 } else if current_snapshot
11038 .provider_session
11039 .as_ref()
11040 .is_some_and(|current| {
11041 !session_registry::same_provider_session(
11042 ¤t_snapshot.provider,
11043 current,
11044 ¤t_snapshot.provider,
11045 &identity,
11046 )
11047 })
11048 {
11049 if let Some(current) = records.records.get_mut(record_id) {
11050 current.active_session = None;
11051 if current.bundle.is_some() || current.context.is_some() {
11052 let had_bundle = current.bundle.is_some();
11053 current.provider_session = None;
11054 current.bundle = None;
11055 current.context_id = None;
11056 current.context = None;
11057 current.state = ManagedSessionState::Unavailable;
11058 current.last_error = Some(if had_bundle {
11059 "bundle-unavailable".to_owned()
11060 } else {
11061 "context-unavailable".to_owned()
11062 });
11063 } else {
11064 current.state = ManagedSessionState::Dormant;
11065 }
11066 current.updated_at_unix_ms = unix_time_ms();
11067 upserts.push(current.clone());
11068 }
11069 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11070 if let Some(current) = records.records.get_mut(record_id) {
11071 current.state = ManagedSessionState::Unavailable;
11072 current.last_error =
11073 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11074 current.updated_at_unix_ms = unix_time_ms();
11075 upserts.push(current.clone());
11076 }
11077 } else if let Some(new_record_id) = replacement_id {
11078 let now = unix_time_ms();
11079 let replacement = ManagedSessionRecord {
11080 record_id: new_record_id.clone(),
11081 display_name: format!(
11082 "{} #{}",
11083 current_snapshot.provider.as_str(),
11084 records
11085 .records
11086 .values()
11087 .filter(|record| {
11088 record.provider == current_snapshot.provider
11089 })
11090 .count()
11091 .saturating_add(1),
11092 ),
11093 provider: current_snapshot.provider.clone(),
11094 mode: current_snapshot.mode,
11095 state: ManagedSessionState::Live,
11096 workspace_id: current_snapshot.workspace_id,
11097 canonical_root: current_snapshot.canonical_root,
11098 provider_session: Some(identity),
11099 active_session: Some(address.clone()),
11100 environment_profile: current_snapshot.environment_profile,
11101 bundle: current_snapshot.bundle,
11102 context_id: current_snapshot.context_id,
11103 context: current_snapshot.context,
11104 exported_context: None,
11110 task_binding: current_snapshot.task_binding,
11111 created_at_unix_ms: now,
11112 updated_at_unix_ms: now,
11113 last_error: None,
11114 };
11115 records
11116 .records
11117 .insert(new_record_id.clone(), replacement.clone());
11118 upserts.push(replacement);
11119 rebind = Some(new_record_id);
11120 } else if let Some(current) = records.records.get_mut(record_id) {
11121 current.state = ManagedSessionState::Unavailable;
11122 current.last_error =
11123 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11124 upserts.push(current.clone());
11125 }
11126 } else if let Some(current) = records.records.get_mut(record_id) {
11127 current.provider_session = Some(identity);
11128 current.active_session = Some(address.clone());
11129 current.state = ManagedSessionState::Live;
11130 current.updated_at_unix_ms = unix_time_ms();
11131 current.last_error = None;
11132 upserts.push(current.clone());
11133 }
11134 } else if let Some(current) = records.records.get_mut(record_id) {
11135 if resume_command_rejected {
11136 current.active_session = None;
11137 current.state = detached_record_state(current);
11138 current.updated_at_unix_ms = unix_time_ms();
11139 current.last_error =
11140 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11141 upserts.push(current.clone());
11142 } else {
11143 match &event.event {
11144 ControlEventKind::Running { .. }
11145 | ControlEventKind::ResumeAuthorized { .. }
11146 | ControlEventKind::Resumed { .. } => {
11147 current.active_session = Some(address.clone());
11148 current.state = active_record_state(
11149 identity_admitted,
11150 current.provider_session.is_some(),
11151 );
11152 current.updated_at_unix_ms = unix_time_ms();
11153 current.last_error = None;
11154 upserts.push(current.clone());
11155 }
11156 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11157 current.active_session = None;
11158 current.state = detached_record_state(current);
11159 current.updated_at_unix_ms = unix_time_ms();
11160 upserts.push(current.clone());
11161 }
11162 ControlEventKind::Failed { message }
11163 | ControlEventKind::ResumeFailed { message } => {
11164 current.active_session = None;
11165 current.state = detached_record_state(current);
11166 current.updated_at_unix_ms = unix_time_ms();
11167 current.last_error = Some(
11168 session_registry::sanitized_record_error_summary(message),
11169 );
11170 upserts.push(current.clone());
11171 }
11172 ControlEventKind::ResumeDenied { reason } => {
11173 current.active_session = None;
11174 current.state = detached_record_state(current);
11175 current.updated_at_unix_ms = unix_time_ms();
11176 current.last_error = Some(
11177 session_registry::sanitized_record_error_summary(reason),
11178 );
11179 upserts.push(current.clone());
11180 }
11181 _ => {}
11182 }
11183 }
11184 }
11185 }
11186 let materialization_owner_record = identity_was_observed.then(|| {
11187 rebind.clone().unwrap_or_else(|| record_id.clone())
11188 }).and_then(|candidate| {
11189 self.session_records
11190 .lock()
11191 .unwrap_or_else(|poisoned| poisoned.into_inner())
11192 .records
11193 .get(&candidate)
11194 .filter(|record| {
11195 record.provider_session.is_some()
11196 && record.active_session.as_ref() == Some(address)
11197 })
11198 .map(|_| candidate)
11199 });
11200 if let Some(owner_record_id) = materialization_owner_record {
11201 let materialization_id = self
11202 .session_bindings
11203 .lock()
11204 .unwrap_or_else(|poisoned| poisoned.into_inner())
11205 .get(&address.session.instance_id)
11206 .and_then(|binding| binding.materialization_id.clone());
11207 if let Some(materialization_id) = materialization_id {
11208 let mut transfer_failed = false;
11209 {
11210 let mut materializations = self
11211 .materializations
11212 .lock()
11213 .unwrap_or_else(|poisoned| poisoned.into_inner());
11214 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11215 match ownership.owner() {
11216 MaterializationOwner::Session {
11217 incarnation_id,
11218 instance_id,
11219 generation,
11220 } if *incarnation_id == self.incarnation_id
11221 && *instance_id == address.session.instance_id
11222 && *generation == address.session.generation =>
11223 {
11224 transfer_failed = ownership
11225 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11226 .is_err();
11227 }
11228 MaterializationOwner::Record { record_id }
11229 if record_id == &owner_record_id => {}
11230 MaterializationOwner::Record { record_id } => {
11231 let expected = record_id.clone();
11232 transfer_failed = ownership
11233 .transfer_record_owner(
11234 &expected,
11235 owner_record_id.clone(),
11236 unix_time_ms(),
11237 )
11238 .is_err();
11239 }
11240 _ => transfer_failed = true,
11241 }
11242 if transfer_failed {
11243 let _ = ownership.mark_recovery_required(unix_time_ms());
11244 }
11245 } else {
11246 transfer_failed = true;
11247 }
11248 }
11249 if transfer_failed {
11250 if let Some(record) = self
11251 .session_records
11252 .lock()
11253 .unwrap_or_else(|poisoned| poisoned.into_inner())
11254 .records
11255 .get_mut(&owner_record_id)
11256 {
11257 record.state = ManagedSessionState::Unavailable;
11258 record.last_error = Some("environment-profile-unavailable".to_owned());
11259 record.updated_at_unix_ms = unix_time_ms();
11260 upserts.push(record.clone());
11261 }
11262 }
11263 }
11264 }
11265 if let Some(new_record_id) = rebind.clone() {
11266 if let Some(binding) = self
11267 .session_bindings
11268 .lock()
11269 .unwrap_or_else(|poisoned| poisoned.into_inner())
11270 .get_mut(&address.session.instance_id)
11271 {
11272 binding.record_id = Some(new_record_id);
11273 }
11274 }
11275 if upserts.is_empty() && removals.is_empty() {
11276 return;
11277 }
11278 if self.persist_state_locked().is_err() {
11279 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11280 let mut records = self
11281 .session_records
11282 .lock()
11283 .unwrap_or_else(|poisoned| poisoned.into_inner());
11284 records.records = original_records;
11285 *self
11286 .materializations
11287 .lock()
11288 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11289 if let Some(current) = records.records.get_mut(record_id) {
11290 current.state = ManagedSessionState::Unavailable;
11291 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11292 current.updated_at_unix_ms = unix_time_ms();
11293 upserts = vec![current.clone()];
11294 } else {
11295 upserts.clear();
11296 }
11297 removals.clear();
11298 drop(records);
11299 if let Some(binding) = self
11300 .session_bindings
11301 .lock()
11302 .unwrap_or_else(|poisoned| poisoned.into_inner())
11303 .get_mut(&address.session.instance_id)
11304 {
11305 binding.record_id = Some(record_id.clone());
11306 }
11307 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11308 }
11309 for record in upserts {
11310 self.publish_record(record);
11311 }
11312 for record_id in removals {
11313 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11314 }
11315 }
11316
11317 fn snapshot(&self) -> NodeSnapshot {
11318 let control = self.handle.snapshot();
11319 let bindings = self
11320 .session_bindings
11321 .lock()
11322 .unwrap_or_else(|poisoned| poisoned.into_inner())
11323 .clone();
11324 let workspace_roots = self
11325 .workspaces
11326 .read()
11327 .unwrap_or_else(|poisoned| poisoned.into_inner())
11328 .clone();
11329 let environment_profiles = self
11330 .environment_profiles
11331 .read()
11332 .unwrap_or_else(|poisoned| poisoned.into_inner());
11333 let mut workspaces = workspace_roots
11334 .iter()
11335 .map(|(workspace_id, canonical_root)| {
11336 (
11337 workspace_id.clone(),
11338 WorkspaceSnapshot {
11339 workspace_id: workspace_id.clone(),
11340 canonical_root: opaque_windows_path(canonical_root.clone()),
11341 sessions: Vec::new(),
11342 worktree_service_mode: Some(
11343 self.worktree_service_modes
11344 .get(workspace_id)
11345 .copied()
11346 .unwrap_or(WorktreeServiceMode::Manual),
11347 ),
11348 managed_worktree_profiles: Some(WorktreeProfileInventory {
11349 profiles: self
11350 .managed_worktree_profiles
11351 .get(workspace_id)
11352 .into_iter()
11353 .flat_map(BTreeMap::values)
11354 .map(|profile| ManagedWorktreeProfileSummary {
11355 id: profile.profile_id().clone(),
11356 revision: profile.revision().clone(),
11357 retention: profile.retention(),
11358 })
11359 .collect(),
11360 }),
11361 },
11362 )
11363 })
11364 .collect::<BTreeMap<_, _>>();
11365 let mut agent_progress = Vec::new();
11366 for session in &control.sessions {
11367 let Some(binding) = bindings.get(&session.instance_id) else {
11368 continue;
11369 };
11370 if binding.generation != session.generation {
11371 continue;
11372 }
11373 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11374 let address = SessionAddress {
11375 workspace_id: binding.workspace_id.clone(),
11376 session: SessionKey {
11377 instance_id: session.instance_id,
11378 generation: session.generation,
11379 },
11380 };
11381 if let Some(progress) =
11382 agent_progress_from_provider_snapshot(address, &session.provider)
11383 {
11384 agent_progress.push(progress);
11385 }
11386 workspace.sessions.push(session.clone());
11387 }
11388 }
11389 NodeSnapshot {
11390 node_id: self.node_id.clone(),
11391 enabled_providers: self.enabled_providers.clone(),
11392 provider_runtime_statuses: {
11393 let updates = self
11394 .provider_runtime_status_updates
11395 .read()
11396 .unwrap_or_else(|poisoned| poisoned.into_inner());
11397 ProviderRuntimeStatuses::new(
11398 self.provider_runtime_statuses
11399 .iter()
11400 .filter(|status| !updates.contains_key(status.provider()))
11401 .cloned()
11402 .chain(updates.values().cloned()),
11403 )
11404 .expect("refreshed provider runtime inventory remains bounded and unique")
11405 },
11406 workspaces: workspaces.into_values().collect(),
11407 session_records: self
11408 .session_records
11409 .lock()
11410 .unwrap_or_else(|poisoned| poisoned.into_inner())
11411 .records
11412 .values()
11413 .cloned()
11414 .collect(),
11415 managed_worktrees: self.managed_worktrees
11416 .lock()
11417 .unwrap_or_else(|poisoned| poisoned.into_inner())
11418 .snapshots(),
11419 launch_inventory: Some(LaunchInventory {
11420 spawn_profiles: Some(
11421 self.spawn_profiles
11422 .iter()
11423 .filter_map(|profile| {
11424 resolved_spawn_profile_summary(profile, &environment_profiles)
11425 })
11426 .collect(),
11427 ),
11428 bundles: Some(
11429 self.bundle_catalog
11430 .read()
11431 .unwrap_or_else(|poisoned| poisoned.into_inner())
11432 .iter()
11433 .map(NodeBundle::receipt)
11434 .collect(),
11435 ),
11436 network_allowlists: {
11439 let catalog = self
11440 .network_allowlist_catalog
11441 .read()
11442 .unwrap_or_else(|poisoned| poisoned.into_inner());
11443 if catalog.is_empty() {
11444 None
11445 } else {
11446 Some(catalog.ids().cloned().collect())
11447 }
11448 },
11449 }),
11450 agent_progress,
11451 }
11452 }
11453
11454 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11455 assert!(
11456 !matches!(
11457 &event,
11458 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11459 ),
11460 "terminal frame and agent stream events must use their own dedicated live channel",
11461 );
11462 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11463 let sequence = history
11464 .last_sequence
11465 .checked_add(1)
11466 .expect("node event sequence exhausted");
11467 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11468 if let Some(evicted) = history.events.pop_front() {
11469 history.replay_floor_sequence = evicted
11470 .sequence
11471 .checked_add(1)
11472 .expect("node replay floor sequence exhausted");
11473 history.removed_record_providers.remove(&evicted.sequence);
11474 }
11475 }
11476 match &event {
11477 NodeEvent::HarnessMcpReadCall { .. } => {
11478 panic!("transient harness MCP calls must not enter durable event history")
11479 }
11480 NodeEvent::SessionRecordUpserted { record } => {
11481 history
11482 .record_providers
11483 .insert(record.record_id.clone(), record.provider.clone());
11484 }
11485 NodeEvent::SessionRecordRemoved { record_id } => {
11486 if let Some(provider) = history.record_providers.remove(record_id) {
11487 history.removed_record_providers.insert(sequence, provider);
11488 }
11489 }
11490 NodeEvent::ManagedWorktreeUpserted { .. }
11491 | NodeEvent::ManagedWorktreeRemoved { .. }
11492 | NodeEvent::Control { .. }
11493 | NodeEvent::ControllerChanged { .. }
11494 | NodeEvent::SessionRecordHistorySummarized { .. }
11495 | NodeEvent::WorkspaceAdded { .. }
11496 | NodeEvent::WorkspaceRemoved { .. }
11497 | NodeEvent::ResyncRequired { .. }
11498 | NodeEvent::AgentStream { .. }
11499 | NodeEvent::TerminalFrame { .. } => {}
11500 }
11501 let envelope = NodeEventEnvelope { sequence, event };
11502 history.events.push_back(envelope.clone());
11503 history.last_sequence = sequence;
11504 let _ = self.event_tx.send(envelope.clone());
11505 drop(history);
11506 envelope
11507 }
11508
11509 fn publish_transient(&self, event: NodeEvent) -> bool {
11520 assert!(event.requires_harness_mcp_proxy_capability());
11521 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11522 let envelope = NodeEventEnvelope { sequence: 0, event };
11523 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11524 Ok(_) => true,
11525 Err(broadcast::error::SendError(undelivered)) => {
11526 if let Some(NodeEventEnvelope {
11527 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11528 ..
11529 }) = undelivered.first() {
11530 tracing::warn!(
11536 call_id = call_id.as_str(),
11537 content_type = ?request.content_type,
11538 cause = "no-subscribers",
11539 "harness MCP proxy event could not be published",
11540 );
11541 }
11542 false
11543 }
11544 }
11545 }
11546
11547 fn publish_terminal_frames(&self) {
11548 let snapshot = self.handle.snapshot();
11549 let bindings = self
11550 .session_bindings
11551 .lock()
11552 .unwrap_or_else(|poisoned| poisoned.into_inner());
11553 let candidates = snapshot
11554 .sessions
11555 .iter()
11556 .filter_map(|session| {
11557 let binding = bindings.get(&session.instance_id)?;
11558 if binding.generation != session.generation {
11559 return None;
11560 }
11561 let frame = session.terminal_frame.clone()?;
11562 crate::session_signals::feed(
11563 binding.workspace_id.as_str(),
11564 session.instance_id.0,
11565 session.agent_id.as_str(),
11566 &frame.contents,
11567 );
11568 Some((
11569 SessionAddress {
11570 workspace_id: binding.workspace_id.clone(),
11571 session: SessionKey {
11572 instance_id: session.instance_id,
11573 generation: session.generation,
11574 },
11575 },
11576 frame,
11577 ))
11578 })
11579 .collect();
11580 drop(bindings);
11581 self.publish_terminal_frame_candidates(candidates);
11582 }
11583
11584 fn publish_terminal_frame_candidates(
11585 &self,
11586 candidates: Vec<(SessionAddress, TerminalFrame)>,
11587 ) {
11588 let mut watermarks = self
11589 .terminal_frame_watermarks
11590 .lock()
11591 .unwrap_or_else(|poisoned| poisoned.into_inner());
11592 let mut events = Vec::with_capacity(candidates.len());
11593 for (address, frame) in candidates {
11594 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11595 let advanced = watermarks
11596 .get(&address.session.instance_id)
11597 .map_or(true, |(current_address, sequence)| {
11598 current_address != &address || frame.sequence > *sequence
11599 });
11600 if !advanced {
11601 continue;
11602 }
11603 if is_first_frame {
11604 if self.controller_state().is_none() {
11618 tracing::warn!(
11619 node_id = %self.node_id,
11620 workspace_id = %address.workspace_id,
11621 session = ?address.session,
11622 terminal_size = ?frame.size,
11623 "session producing output with no controller attached",
11624 );
11625 } else {
11626 tracing::info!(
11627 node_id = %self.node_id,
11628 workspace_id = %address.workspace_id,
11629 session = ?address.session,
11630 terminal_size = ?frame.size,
11631 "session produced its first output",
11632 );
11633 }
11634 }
11635 watermarks.insert(
11636 address.session.instance_id,
11637 (address.clone(), frame.sequence),
11638 );
11639 if let Some((at, reported)) = self
11640 .pty_input_marks
11641 .lock()
11642 .unwrap_or_else(|poisoned| poisoned.into_inner())
11643 .get_mut(&address.session.instance_id)
11644 {
11645 if !*reported && at.elapsed() < Duration::from_secs(5) {
11647 *reported = true;
11648 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");
11649 }
11650 }
11651 events.push(NodeEvent::TerminalFrame { address, frame });
11652 }
11653 drop(watermarks);
11654 if events.is_empty() {
11655 return;
11656 }
11657 let mut history = self
11658 .history
11659 .lock()
11660 .unwrap_or_else(|poisoned| poisoned.into_inner());
11661 let mut envelopes = Vec::with_capacity(events.len());
11662 for event in events {
11663 let sequence = history
11664 .last_sequence
11665 .checked_add(1)
11666 .expect("node event sequence exhausted");
11667 history.last_sequence = sequence;
11668 envelopes.push(NodeEventEnvelope { sequence, event });
11669 }
11670 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11671 drop(history);
11672 }
11673
11674 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11683 let mut history = self
11684 .history
11685 .lock()
11686 .unwrap_or_else(|poisoned| poisoned.into_inner());
11687 let sequence = history
11688 .last_sequence
11689 .checked_add(1)
11690 .expect("node event sequence exhausted");
11691 history.last_sequence = sequence;
11692 drop(history);
11693 tracing::debug!(
11701 source_sequence = chunk.source_sequence,
11702 kind = agent_stream_chunk_kind_label(&chunk.kind),
11703 envelope_sequence = sequence,
11704 "publishing agent stream chunk",
11705 );
11706 let envelope = NodeEventEnvelope {
11707 sequence,
11708 event: NodeEvent::AgentStream { address, chunk },
11709 };
11710 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11711 }
11712
11713 fn current_sequence(&self) -> u64 {
11714 self.history
11715 .lock()
11716 .unwrap_or_else(|poisoned| poisoned.into_inner())
11717 .last_sequence
11718 }
11719
11720 fn controller_state(&self) -> Option<ControllerState> {
11721 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11722 let lease = controller.as_ref().copied()?;
11723 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11724 if remaining.is_zero() {
11725 *controller = None;
11726 return None;
11727 }
11728 Some(ControllerState {
11729 connection_id: lease.connection_id,
11730 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11731 })
11732 }
11733
11734 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11735 if role != ClientRole::Operator {
11736 tracing::warn!(
11737 node_id = %self.node_id,
11738 connection_id,
11739 "controller lease rejected: observer connections cannot acquire control",
11740 );
11741 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11742 }
11743 let lease_ms = if lease_ms == 0 {
11744 DEFAULT_CONTROLLER_LEASE_MS
11745 } else {
11746 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11747 };
11748 let now = Instant::now();
11749 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11750 if let Some(active) = controller.as_ref() {
11751 if active.connection_id != connection_id && active.expires_at > now {
11752 tracing::warn!(
11753 node_id = %self.node_id,
11754 connection_id,
11755 holder_connection_id = active.connection_id,
11756 "controller lease rejected: another operator holds it",
11757 );
11758 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11759 }
11760 }
11761 let is_new_attach = match controller.as_ref() {
11768 Some(active) => active.connection_id != connection_id,
11769 None => true,
11770 };
11771 *controller = Some(ControllerLease {
11772 connection_id,
11773 expires_at: now + Duration::from_millis(lease_ms),
11774 });
11775 drop(controller);
11776 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11777 if is_new_attach {
11778 tracing::info!(
11779 node_id = %self.node_id,
11780 connection_id,
11781 lease_ms,
11782 "controller lease acquired",
11783 );
11784 } else {
11785 tracing::debug!(
11786 node_id = %self.node_id,
11787 connection_id,
11788 lease_ms,
11789 "controller lease renewed",
11790 );
11791 }
11792 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11793 Ok(state)
11794 }
11795
11796 fn release_controller(&self, connection_id: u64) -> bool {
11797 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11798 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11799 if released {
11800 *controller = None;
11801 }
11802 drop(controller);
11803 if released {
11804 tracing::info!(
11805 node_id = %self.node_id,
11806 connection_id,
11807 "controller lease released",
11808 );
11809 self.publish(NodeEvent::ControllerChanged { controller: None });
11810 }
11811 released
11812 }
11813
11814 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11823 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11824 return;
11825 };
11826 let id = address.session.instance_id;
11827 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11828 return;
11829 };
11830 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11831 .iter()
11832 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11833 .collect();
11834 let (writes, set_model, effort_gap) =
11835 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11836 if set_model {
11837 if let Some(model_id) = model.clone() {
11838 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11839 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11840 }
11841 }
11842 }
11843 for (option_id, value_json) in writes {
11844 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11845 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11846 }
11847 }
11848 if effort_gap {
11849 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11850 }
11851 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11852 }
11853
11854 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11857 let id = address.session.instance_id;
11858 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11859 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11860 crate::auto_permit::forget_acp(id.0);
11861 return;
11862 }
11863 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11864 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11865 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11866 }
11867 match provider_event {
11868 ProviderEvent::ModeChanged { mode_id, available } => {
11869 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11870 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11871 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11872 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11873 }
11874 }
11875 }
11876 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11877 match self.dispatch(ControlCommand::ResolveInteraction {
11878 instance_id: id,
11879 generation: address.session.generation,
11880 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11881 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11882 }) {
11883 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11884 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11885 }
11886 }
11887 _ => {}
11890 }
11891 }
11892
11893 fn mark_spawn_provisional(&self, address: SessionAddress) {
11894 let id = address.session.instance_id;
11895 self.provisional_spawns
11896 .lock()
11897 .unwrap_or_else(|poisoned| poisoned.into_inner())
11898 .insert(id, (address, Instant::now()));
11899 tracing::info!(
11900 node_id = %self.node_id,
11901 instance_id = id.0,
11902 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11903 "draft spawn; waiting for HQ view",
11904 );
11905 }
11906
11907 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11908 let removed = self
11909 .provisional_spawns
11910 .lock()
11911 .unwrap_or_else(|poisoned| poisoned.into_inner())
11912 .remove(&instance_id);
11913 if removed.is_some() {
11914 tracing::info!(
11915 node_id = %self.node_id,
11916 instance_id = instance_id.0,
11917 "draft spawn claimed by HQ view",
11918 );
11919 }
11920 }
11921
11922 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11923 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11924 let mut map = self
11925 .provisional_spawns
11926 .lock()
11927 .unwrap_or_else(|poisoned| poisoned.into_inner());
11928 let due: Vec<AgentInstanceId> = map
11929 .iter()
11930 .filter(|(_, (_, at))| at.elapsed() >= grace)
11931 .map(|(id, _)| *id)
11932 .collect();
11933 due.into_iter()
11934 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11935 .collect()
11936 }
11937
11938 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11939 let instance_id = address.session.instance_id;
11940 tracing::warn!(
11941 node_id = %self.node_id,
11942 instance_id = instance_id.0,
11943 workspace = %address.workspace_id,
11944 reason = "attach_never_arrived",
11945 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11946 "draft spawn never viewed; stopping PTY",
11947 );
11948 let _ = self.dispatch(ControlCommand::Stop {
11949 instance_id,
11950 force: true,
11951 });
11952 let _ = self.remove_session(&address).await;
11953 }
11954
11955 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11956 if role != ClientRole::Operator {
11957 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11958 }
11959 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11960 let now = Instant::now();
11961 match controller.as_ref().copied() {
11962 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11963 Some(active) if active.expires_at <= now => {
11964 *controller = None;
11965 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11966 }
11967 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11968 }
11969 }
11970
11971 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11972 if !self
11973 .workspaces
11974 .read()
11975 .unwrap_or_else(|poisoned| poisoned.into_inner())
11976 .contains_key(&address.workspace_id)
11977 {
11978 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11979 }
11980 let binding = self
11981 .session_bindings
11982 .lock()
11983 .unwrap_or_else(|poisoned| poisoned.into_inner())
11984 .get(&address.session.instance_id)
11985 .cloned()
11986 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11987 if binding.workspace_id != address.workspace_id {
11988 return Err(failure(
11989 NodeFailureCode::SessionWorkspaceMismatch,
11990 "session belongs to another workspace",
11991 ));
11992 }
11993 if binding.generation != address.session.generation {
11994 return Err(failure(
11995 NodeFailureCode::StaleGeneration,
11996 &format!(
11997 "stale session generation: expected {}, received {}",
11998 binding.generation.0,
11999 address.session.generation.0,
12000 ),
12001 ));
12002 }
12003 let snapshot = self.handle.snapshot();
12004 let Some(current) = snapshot
12005 .sessions
12006 .iter()
12007 .find(|item| item.instance_id == address.session.instance_id)
12008 else {
12009 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12010 };
12011 if current.generation != address.session.generation {
12012 return Err(failure(
12013 NodeFailureCode::StaleGeneration,
12014 &format!(
12015 "stale session generation: expected {}, received {}",
12016 current.generation.0,
12017 address.session.generation.0,
12018 ),
12019 ));
12020 }
12021 Ok(current.agent_id.clone())
12022 }
12023
12024 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12025 self.dispatch_envelope(self.prepare_command(command))
12026 }
12027
12028 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12029 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12030 CommandEnvelope {
12031 id: CommandId(id),
12032 command,
12033 }
12034 }
12035
12036 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12037 let command_id = envelope.id;
12038 self.handle
12039 .dispatch(envelope)
12040 .map_err(|error| match error {
12041 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12042 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12043 })?;
12044 Ok(command_id)
12045 }
12046
12047 fn record_rejected_command(
12056 &self,
12057 command_id: CommandId,
12058 message: String,
12059 unsupported_transport: bool,
12060 ) {
12061 let now = Instant::now();
12062 let mut reasons = self.rejected_command_reasons.lock()
12063 .unwrap_or_else(|poisoned| poisoned.into_inner());
12064 reasons.retain(|_, reason| reason.expires_at > now);
12065 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12066 if let Some(&oldest) = reasons.keys().next() {
12067 reasons.remove(&oldest);
12068 }
12069 }
12070 reasons.insert(
12071 command_id,
12072 RejectedCommandReason {
12073 message,
12074 unsupported_transport,
12075 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12076 },
12077 );
12078 }
12079
12080 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12087 let now = Instant::now();
12088 let mut reasons = self.rejected_command_reasons.lock()
12089 .unwrap_or_else(|poisoned| poisoned.into_inner());
12090 match reasons.remove(&command_id) {
12091 Some(reason) if reason.expires_at > now => Some(reason),
12092 _ => None,
12093 }
12094 }
12095
12096 async fn dispatch_bounded(
12097 &self,
12098 command: ControlCommand,
12099 timeout_duration: Duration,
12100 ) -> Result<CommandId, NodeFailure> {
12101 let deadline = Instant::now() + timeout_duration;
12102 loop {
12103 match self.dispatch(command.clone()) {
12104 Ok(command_id) => return Ok(command_id),
12105 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12106 sleep(Duration::from_millis(2)).await;
12107 }
12108 Err(error) => return Err(error),
12109 }
12110 }
12111 }
12112
12113 fn internal_session_snapshot(
12114 &self,
12115 address: &SessionAddress,
12116 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12117 self.validate_address(address)?;
12118 self.handle
12119 .snapshot()
12120 .sessions
12121 .clone()
12122 .into_iter()
12123 .find(|session| {
12124 session.instance_id == address.session.instance_id
12125 && session.generation == address.session.generation
12126 })
12127 .ok_or_else(|| {
12128 failure(
12129 NodeFailureCode::UnknownSession,
12130 "session snapshot is unavailable",
12131 )
12132 })
12133 }
12134
12135 fn require_acp_session(
12143 &self,
12144 address: &SessionAddress,
12145 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12146 let snapshot = self.internal_session_snapshot(address)?;
12147 if snapshot.transport != TransportKind::Acp {
12148 return Err(failure(
12149 NodeFailureCode::UnsupportedTransport,
12150 "this verb requires a session spawned over the ACP transport",
12151 ));
12152 }
12153 Ok(snapshot)
12154 }
12155
12156 async fn dispatch_history_bounded(
12157 &self,
12158 address: &SessionAddress,
12159 command: ControlCommand,
12160 operation: HistoryOperation,
12161 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12162 self.validate_address(address)?;
12163 let started_after = self.current_sequence();
12164 let command_id = self
12165 .dispatch_bounded(
12166 command,
12167 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12168 )
12169 .await?;
12170 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12171 let mut scan_after = started_after;
12172 let mut accepted_after = None;
12173 loop {
12174 let events = self
12175 .history
12176 .lock()
12177 .unwrap_or_else(|poisoned| poisoned.into_inner())
12178 .events
12179 .iter()
12180 .filter(|envelope| envelope.sequence > scan_after)
12181 .cloned()
12182 .collect::<Vec<_>>();
12183 for envelope in events {
12184 scan_after = scan_after.max(envelope.sequence);
12185 let NodeEvent::Control {
12186 address: event_address,
12187 event,
12188 } = envelope.event
12189 else {
12190 continue;
12191 };
12192 if event_address != *address {
12193 continue;
12194 }
12195 if event.command_id == Some(command_id) {
12196 match &event.event {
12197 ControlEventKind::CommandRejected { .. } => {
12198 return Err(failure(
12199 NodeFailureCode::InvalidRequest,
12200 "history request was rejected",
12201 ));
12202 }
12203 ControlEventKind::HistoryRequested {
12204 operation: requested,
12205 ..
12206 } if requested == &operation => {
12207 accepted_after = Some(envelope.sequence);
12208 continue;
12209 }
12210 _ => {}
12211 }
12212 }
12213 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12214 match (&operation, &event.event) {
12215 (
12216 HistoryOperation::Discover { .. },
12217 ControlEventKind::HistoryDiscovered { .. },
12218 )
12219 | (
12220 HistoryOperation::Load { .. },
12221 ControlEventKind::HistoryLoaded { .. },
12222 ) => return self.internal_session_snapshot(address),
12223 (_, ControlEventKind::HistoryFailed { .. }) => {
12224 return Err(failure(
12225 NodeFailureCode::BackendOperationFailed,
12226 "history operation failed",
12227 ));
12228 }
12229 _ => {}
12230 }
12231 }
12232 }
12233 if Instant::now() >= deadline {
12234 return Err(failure(
12235 NodeFailureCode::BackendBusy,
12236 if accepted_after.is_some() {
12237 "history operation did not settle before the bounded deadline"
12238 } else {
12239 "history operation was not accepted before the bounded deadline"
12240 },
12241 ));
12242 }
12243 sleep(Duration::from_millis(2)).await;
12244 }
12245 }
12246
12247 async fn discover_history(
12248 &self,
12249 address: &SessionAddress,
12250 limit: u16,
12251 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12252 let query = HistoryQuery {
12253 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12254 limit,
12255 };
12256 query
12257 .validate()
12258 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12259 let snapshot = self
12260 .dispatch_history_bounded(
12261 address,
12262 ControlCommand::DiscoverHistory {
12263 instance_id: address.session.instance_id,
12264 query: query.clone(),
12265 },
12266 HistoryOperation::Discover { query },
12267 )
12268 .await?;
12269 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12270 return Err(failure(
12271 NodeFailureCode::BackendOperationFailed,
12272 "history discovery settled into an invalid state",
12273 ));
12274 }
12275 Ok(snapshot.history.candidates)
12276 }
12277
12278 async fn load_history(
12279 &self,
12280 address: &SessionAddress,
12281 candidate_id: String,
12282 ) -> Result<HistorySessionRecord, NodeFailure> {
12283 validate_candidate_id(&candidate_id)
12284 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12285 let snapshot = self
12286 .dispatch_history_bounded(
12287 address,
12288 ControlCommand::LoadHistory {
12289 instance_id: address.session.instance_id,
12290 candidate_id: candidate_id.clone(),
12291 },
12292 HistoryOperation::Load {
12293 candidate_id: candidate_id.clone(),
12294 },
12295 )
12296 .await?;
12297 if snapshot.history.pending.is_some()
12298 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12299 {
12300 return Err(failure(
12301 NodeFailureCode::BackendOperationFailed,
12302 "history load settled into an invalid state",
12303 ));
12304 }
12305 snapshot.history.loaded.ok_or_else(|| {
12306 failure(
12307 NodeFailureCode::BackendOperationFailed,
12308 "history load produced no bounded session",
12309 )
12310 })
12311 }
12312
12313 async fn export_context_pack_for_session_record(
12320 &self,
12321 record_id: &SessionRecordId,
12322 session: &SessionAddress,
12323 allow_clean_detachment: bool,
12324 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12325 let outcome = self
12331 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12332 .await;
12333 if let Err(failure) = &outcome {
12334 tracing::warn!(
12335 record = %record_id,
12336 code = ?failure.code,
12337 reason = %failure.message,
12338 "session-record context-pack export refused",
12339 );
12340 }
12341 outcome
12342 }
12343
12344 async fn export_context_pack_for_session_record_inner(
12345 &self,
12346 record_id: &SessionRecordId,
12347 session: &SessionAddress,
12348 allow_clean_detachment: bool,
12349 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12350 let step = |name: &'static str| move |failure: NodeFailure| {
12355 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12356 failure
12357 };
12358 let record = self.record(record_id).map_err(step("record-lookup"))?;
12359 let identity = match record.provider_session.clone() {
12375 Some(identity) => {
12376 if identity.key != ProviderSessionKey::SessionId {
12377 return Err(step("provider-identity-key")(failure(
12378 NodeFailureCode::BackendOperationFailed,
12379 "managed session provider identity cannot bind history exactly",
12380 )));
12381 }
12382 Some(identity)
12383 }
12384 None if record.state == ManagedSessionState::IdentityPending => {
12385 return Err(step("provider-session-identity")(failure(
12386 NodeFailureCode::BackendOperationFailed,
12387 "managed session has not yet observed its provider session identity",
12388 )));
12389 }
12390 None => None,
12391 };
12392 self.revalidate_session_record_context_export(
12393 &record,
12394 identity.as_ref(),
12395 session,
12396 allow_clean_detachment,
12397 ).map_err(step("revalidate-before-discovery"))?;
12398 let candidates = self
12399 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12400 .await
12401 .map_err(step("discover-history"))?;
12402 let candidate_id = select_history_candidate(
12407 &candidates,
12408 identity.as_ref().map(|identity| identity.id.as_str()),
12409 ).map_err(step("unique-candidate"))?;
12410 let loaded = self.load_history(session, candidate_id.clone()).await
12411 .map_err(step("load-history"))?;
12412 if let Some(identity) = identity.as_ref() {
12413 if loaded.session_id != identity.id {
12414 return Err(failure(
12415 NodeFailureCode::SessionRecordConflict,
12416 "loaded history identity does not match the managed session record",
12417 ));
12418 }
12419 }
12420 self.revalidate_session_record_context_export(
12421 &record,
12422 identity.as_ref(),
12423 session,
12424 allow_clean_detachment,
12425 )?;
12426 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12427 .map_err(step("revalidate-loaded-history"))?;
12428 let pack = self.materialize_context_pack(session).await
12429 .map_err(step("materialize-context-pack"))?;
12430 self.commit_context_pack_for_session_record(
12431 &record,
12432 identity.as_ref(),
12433 session,
12434 &candidate_id,
12435 &loaded,
12436 pack,
12437 allow_clean_detachment,
12438 )
12439 }
12440
12441 async fn export_context_pack(
12442 &self,
12443 address: &SessionAddress,
12444 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12445 let pack = self.materialize_context_pack(address).await?;
12446 self.commit_context_pack(pack)
12447 }
12448
12449 async fn materialize_context_pack(
12450 &self,
12451 address: &SessionAddress,
12452 ) -> Result<NodeContextPack, NodeFailure> {
12453 let snapshot = self.internal_session_snapshot(address)?;
12454 if !context_pack_source_status_is_usable(&snapshot.status) {
12455 return Err(failure(
12456 NodeFailureCode::InvalidRequest,
12457 "context export requires a running or successfully exited source session",
12458 ));
12459 }
12460 if snapshot.history.pending.is_some()
12461 || snapshot.history.loaded_candidate_id.is_none()
12462 {
12463 return Err(failure(
12464 NodeFailureCode::InvalidRequest,
12465 "context export requires settled loaded history",
12466 ));
12467 }
12468 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12469 failure(
12470 NodeFailureCode::InvalidRequest,
12471 "context export requires settled loaded history",
12472 )
12473 })?;
12474 let source_provider = snapshot.agent_id.clone();
12475 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12476 let repository = self
12477 .context_pack_repository(&address.workspace_id)
12478 .await?;
12479 let current = self.internal_session_snapshot(address)?;
12480 if !context_pack_source_status_is_usable(¤t.status)
12481 || current.agent_id != source_provider
12482 || current.history.pending.is_some()
12483 || current.history.loaded_candidate_id != loaded_candidate_id
12484 || current.history.loaded.as_ref() != Some(&loaded)
12485 {
12486 return Err(failure(
12487 NodeFailureCode::ContextPackMaterializationFailed,
12488 "context source changed during bounded repository capture",
12489 ));
12490 }
12491 NodeContextPack::export_with_repository(
12492 ContextPackLineageReceipt {
12493 source_node_id: self.node_id.clone(),
12494 source_session: address.clone(),
12495 source_provider,
12496 },
12497 &loaded,
12498 Some(repository),
12499 )
12500 .map_err(|_| {
12501 failure(
12502 NodeFailureCode::ContextPackMaterializationFailed,
12503 "bounded context pack export failed",
12504 )
12505 })
12506 }
12507
12508 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12514 let mut store = self
12515 .context_pack_store
12516 .lock()
12517 .unwrap_or_else(|poisoned| poisoned.into_inner());
12518 let Some(store) = store.as_mut() else {
12519 return Ok(());
12520 };
12521 store.commit(pack).map_err(|_| {
12522 failure(
12523 NodeFailureCode::ContextPackMaterializationFailed,
12524 "durable context pack commit failed",
12525 )
12526 })
12527 }
12528
12529 fn commit_context_pack(
12530 &self,
12531 pack: NodeContextPack,
12532 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12533 self.commit_context_pack_durably(&pack)?;
12534 self.context_catalog
12535 .write()
12536 .unwrap_or_else(|poisoned| poisoned.into_inner())
12537 .insert(pack)
12538 .map_err(|_| {
12539 failure(
12540 NodeFailureCode::ContextPackMaterializationFailed,
12541 "context pack catalog is full",
12542 )
12543 })
12544 }
12545
12546 fn commit_context_pack_for_session_record(
12547 &self,
12548 expected: &ManagedSessionRecord,
12549 identity: Option<&ProviderSessionIdentity>,
12550 session: &SessionAddress,
12551 candidate_id: &str,
12552 loaded: &HistorySessionRecord,
12553 pack: NodeContextPack,
12554 allow_clean_detachment: bool,
12555 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12556 let current_root = self.workspace_root(&expected.workspace_id)?;
12557 let _transaction = self
12558 .state_transaction
12559 .lock()
12560 .unwrap_or_else(|poisoned| poisoned.into_inner());
12561 let records = self
12562 .session_records
12563 .lock()
12564 .unwrap_or_else(|poisoned| poisoned.into_inner());
12565 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12566 failure(
12567 NodeFailureCode::SessionRecordConflict,
12568 "managed session record disappeared during context export",
12569 )
12570 })?;
12571 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12572 return Err(failure(
12573 NodeFailureCode::SessionRecordConflict,
12574 "managed session record changed during context export",
12575 ));
12576 }
12577 let bindings = self
12578 .session_bindings
12579 .lock()
12580 .unwrap_or_else(|poisoned| poisoned.into_inner());
12581 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12582 failure(
12583 NodeFailureCode::SessionRecordConflict,
12584 "managed session runtime binding disappeared during context export",
12585 )
12586 })?;
12587 let runtime = self
12588 .handle
12589 .snapshot()
12590 .sessions
12591 .iter()
12592 .find(|snapshot| {
12593 snapshot.instance_id == session.session.instance_id
12594 && snapshot.generation == session.session.generation
12595 })
12596 .cloned()
12597 .ok_or_else(|| {
12598 failure(
12599 NodeFailureCode::SessionRecordConflict,
12600 "managed session runtime disappeared during context export",
12601 )
12602 })?;
12603 let receipt = pack.receipt();
12604 if !session_record_context_export_binding_is_exact(
12605 expected,
12606 current,
12607 identity,
12608 session,
12609 binding,
12610 &runtime.agent_id,
12611 ¤t_root,
12612 allow_clean_detachment,
12613 ) || !session_record_context_export_source_is_usable(
12614 current,
12615 &runtime.status,
12616 allow_clean_detachment,
12617 ) || runtime.history.pending.is_some()
12618 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12619 || runtime.history.loaded.as_ref() != Some(loaded)
12620 || receipt.lineage.source_node_id != self.node_id
12621 || receipt.lineage.source_session != *session
12622 || receipt.lineage.source_provider != current.provider
12623 || receipt.source_message_count != loaded.message_count
12624 {
12625 return Err(failure(
12626 NodeFailureCode::SessionRecordConflict,
12627 "managed session source changed before context catalog commit",
12628 ));
12629 }
12630 self.commit_context_pack_durably(&pack)?;
12631 self.context_catalog
12632 .write()
12633 .unwrap_or_else(|poisoned| poisoned.into_inner())
12634 .insert(pack)
12635 .map_err(|_| {
12636 failure(
12637 NodeFailureCode::ContextPackMaterializationFailed,
12638 "context pack catalog is full",
12639 )
12640 })
12641 }
12642
12643 async fn context_pack_repository(
12644 &self,
12645 workspace_id: &WorkspaceId,
12646 ) -> Result<ContextPackRepository, NodeFailure> {
12647 let first = self
12648 .context_pack_repository_observation(workspace_id)
12649 .await?;
12650 let second = self
12651 .context_pack_repository_observation(workspace_id)
12652 .await?;
12653 stable_context_pack_repository(first, second)
12654 }
12655
12656 async fn context_pack_repository_observation(
12657 &self,
12658 workspace_id: &WorkspaceId,
12659 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12660 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12661 let head = if git.is_repository {
12662 let root = self.workspace_root(workspace_id)?;
12663 match resolve_base_commit_with_timeout(
12664 &root,
12665 "HEAD",
12666 GIT_COMMAND_TIMEOUT_MS,
12667 )
12668 .await
12669 {
12670 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12671 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12672 ContextPackRepositoryHead::Unborn
12673 }
12674 Err(_) => {
12675 return Err(failure(
12676 NodeFailureCode::ContextPackMaterializationFailed,
12677 "context pack HEAD observation failed",
12678 ));
12679 }
12680 }
12681 } else {
12682 ContextPackRepositoryHead::NotRepository
12683 };
12684 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12685 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12686 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12687 failure(
12688 NodeFailureCode::InvalidRepositoryPath,
12689 "context pack selected file path is invalid",
12690 )
12691 })?;
12692 match self.read_workspace_file(workspace_id.clone(), path).await {
12693 Ok(WorkspaceFileRead {
12694 content: WorkspaceFileContent::Utf8 { text, byte_len },
12695 ..
12696 }) => files.push(ContextPackRepositoryFileSource::utf8(
12697 selected_path,
12698 text,
12699 byte_len,
12700 )),
12701 Ok(WorkspaceFileRead {
12702 content: WorkspaceFileContent::NonUtf8 { byte_len },
12703 ..
12704 }) => files.push(ContextPackRepositoryFileSource::skipped(
12705 selected_path,
12706 ContextPackSelectedFileSkipReason::NonUtf8,
12707 Some(byte_len),
12708 )),
12709 Ok(WorkspaceFileRead {
12710 content: WorkspaceFileContent::TooLarge { .. },
12711 ..
12712 }) => files.push(ContextPackRepositoryFileSource::skipped(
12713 selected_path,
12714 ContextPackSelectedFileSkipReason::TooLarge,
12715 None,
12716 )),
12717 Err(error) => {
12718 let reason = if matches!(
12719 error.code,
12720 NodeFailureCode::InvalidRepositoryPath
12721 | NodeFailureCode::RepositoryFileNotRegular
12722 | NodeFailureCode::RepositoryPathUnsafe
12723 ) {
12724 ContextPackSelectedFileSkipReason::Unsafe
12725 } else {
12726 ContextPackSelectedFileSkipReason::Unavailable
12727 };
12728 files.push(ContextPackRepositoryFileSource::skipped(
12729 selected_path,
12730 reason,
12731 None,
12732 ));
12733 }
12734 }
12735 }
12736 Ok(ContextPackRepositoryObservation { head, git, files })
12737 }
12738
12739 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12740 let referenced_by_binding = self
12741 .session_bindings
12742 .lock()
12743 .unwrap_or_else(|poisoned| poisoned.into_inner())
12744 .values()
12745 .any(|binding| {
12746 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12747 });
12748 let referenced_by_record = self
12749 .session_records
12750 .lock()
12751 .unwrap_or_else(|poisoned| poisoned.into_inner())
12752 .records
12753 .values()
12754 .any(|record| record.context_id.as_ref() == Some(context_id));
12755 let referenced_by_materialization = self
12756 .materializations
12757 .lock()
12758 .unwrap_or_else(|poisoned| poisoned.into_inner())
12759 .values()
12760 .any(|ownership| {
12761 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12762 });
12763 if referenced_by_binding
12764 || referenced_by_record
12765 || referenced_by_materialization
12766 {
12767 return Err(failure(
12768 NodeFailureCode::ContextPackBusy,
12769 "context pack is still referenced",
12770 ));
12771 }
12772 let mut catalog = self.context_catalog
12773 .write()
12774 .unwrap_or_else(|poisoned| poisoned.into_inner());
12775 if catalog.get(context_id).is_none() {
12776 return Err(failure(
12777 NodeFailureCode::UnknownContextPack,
12778 "context pack is unavailable",
12779 ));
12780 }
12781 self.spawn_idempotency
12782 .lock()
12783 .unwrap_or_else(|poisoned| poisoned.into_inner())
12784 .entries
12785 .retain(|_, entry| !entry.value.references_context(context_id));
12786 let removed = catalog.remove(context_id);
12787 debug_assert!(removed.is_some(), "validated context pack remains present");
12788 Ok(())
12789 }
12790
12791 async fn dispatch_input_bounded(
12792 &self,
12793 address: &SessionAddress,
12794 action: InputAction,
12795 ) -> Result<(), NodeFailure> {
12796 let started_after = self.current_sequence();
12797 self.pty_input_marks
12798 .lock()
12799 .unwrap_or_else(|poisoned| poisoned.into_inner())
12800 .insert(address.session.instance_id, (Instant::now(), false));
12801 self.pty_input_wake.notify_one();
12802 let command_id = self
12803 .dispatch_bounded(
12804 ControlCommand::SendInput {
12805 instance_id: address.session.instance_id,
12806 action,
12807 },
12808 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12809 )
12810 .await?;
12811 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12815 let mut scan_after = started_after;
12816 let mut accepted_after = None;
12817 loop {
12818 let events = self
12819 .history
12820 .lock()
12821 .unwrap_or_else(|poisoned| poisoned.into_inner())
12822 .events
12823 .iter()
12824 .filter(|envelope| envelope.sequence > scan_after)
12825 .cloned()
12826 .collect::<Vec<_>>();
12827 for envelope in events {
12828 scan_after = scan_after.max(envelope.sequence);
12829 let NodeEvent::Control {
12830 address: event_address,
12831 event,
12832 } = envelope.event
12833 else {
12834 continue;
12835 };
12836 if event_address != *address {
12837 continue;
12838 }
12839 if event.command_id == Some(command_id) {
12840 match &event.event {
12841 ControlEventKind::CommandRejected { message } => {
12842 return Err(failure(NodeFailureCode::InvalidRequest, message));
12843 }
12844 ControlEventKind::InputRequested { .. } => {
12845 accepted_after = Some(envelope.sequence);
12846 continue;
12847 }
12848 _ => {}
12849 }
12850 }
12851 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12852 match &event.event {
12853 ControlEventKind::InputCompleted { .. } => return Ok(()),
12854 ControlEventKind::InputFailed { message, .. } => {
12855 return Err(failure(
12856 NodeFailureCode::BackendOperationFailed,
12857 message,
12858 ));
12859 }
12860 _ => {}
12861 }
12862 }
12863 }
12864 if Instant::now() >= deadline {
12865 return Err(failure(
12866 NodeFailureCode::BackendBusy,
12867 if accepted_after.is_some() {
12868 "input was accepted but did not settle before the bounded deadline"
12869 } else {
12870 "input was not accepted before the bounded deadline"
12871 },
12872 ));
12873 }
12874 sleep(Duration::from_millis(2)).await;
12875 }
12876 }
12877
12878 async fn dispatch_resize_bounded(
12879 &self,
12880 address: &SessionAddress,
12881 size: gate4agent_types::TerminalSize,
12882 ) -> Result<(), NodeFailure> {
12883 let started_after = self.current_sequence();
12884 let command_id = self
12885 .dispatch_bounded(
12886 ControlCommand::Resize {
12887 instance_id: address.session.instance_id,
12888 size,
12889 },
12890 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12891 )
12892 .await?;
12893 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12894 let mut scan_after = started_after;
12895 let mut accepted_after = None;
12896 loop {
12897 let events = self
12898 .history
12899 .lock()
12900 .unwrap_or_else(|poisoned| poisoned.into_inner())
12901 .events
12902 .iter()
12903 .filter(|envelope| envelope.sequence > scan_after)
12904 .cloned()
12905 .collect::<Vec<_>>();
12906 for envelope in events {
12907 scan_after = scan_after.max(envelope.sequence);
12908 let NodeEvent::Control {
12909 address: event_address,
12910 event,
12911 } = envelope.event
12912 else {
12913 continue;
12914 };
12915 if event_address != *address {
12916 continue;
12917 }
12918 if event.command_id == Some(command_id) {
12919 match &event.event {
12920 ControlEventKind::CommandRejected { message } => {
12921 return Err(failure(NodeFailureCode::InvalidRequest, message));
12922 }
12923 ControlEventKind::ResizeRequested {
12924 size: requested_size,
12925 ..
12926 } if *requested_size == size => {
12927 accepted_after = Some(envelope.sequence);
12928 continue;
12929 }
12930 _ => {}
12931 }
12932 }
12933 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12934 match &event.event {
12935 ControlEventKind::Resized { size: settled_size }
12936 if *settled_size == size =>
12937 {
12938 return Ok(());
12939 }
12940 ControlEventKind::ResizeFailed { message } => {
12941 return Err(failure(
12942 NodeFailureCode::BackendOperationFailed,
12943 message,
12944 ));
12945 }
12946 _ => {}
12947 }
12948 }
12949 }
12950 if Instant::now() >= deadline {
12951 return Err(failure(
12952 NodeFailureCode::BackendBusy,
12953 if accepted_after.is_some() {
12954 "resize was accepted but did not settle before the bounded deadline"
12955 } else {
12956 "resize was not accepted before the bounded deadline"
12957 },
12958 ));
12959 }
12960 sleep(Duration::from_millis(2)).await;
12961 }
12962 }
12963
12964 async fn spawn_session(
12965 &self,
12966 workspace_id: WorkspaceId,
12967 provider: AgentId,
12968 mode: SessionMode,
12969 terminal_size: gate4agent_types::TerminalSize,
12970 initial_prompt: Option<String>,
12971 ) -> Result<SessionAddress, NodeFailure> {
12972 if self.managed_worktrees.lock()
12973 .unwrap_or_else(|poisoned| poisoned.into_inner())
12974 .lease_for_workspace(&workspace_id)
12975 .is_some()
12976 {
12977 return Err(failure(
12978 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12979 "managed worktree targets may only be spawned through their lease request",
12980 ));
12981 }
12982 self
12983 .spawn_session_with_deadline(
12984 workspace_id,
12985 provider.clone(),
12986 mode,
12987 terminal_size,
12988 initial_prompt,
12989 None,
12990 None,
12991 None,
12992 None,
12993 None,
12994 SpawnRecordPolicy::ProviderIdentityOnly,
12995 None,
12996 &[],
12997 None,
12998 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13005 None,
13006 None,
13007 )
13008 .await
13009 .map(|(session, _runtime_policy)| session)
13010 }
13011
13012 async fn spawn_session_with_deadline(
13013 &self,
13014 workspace_id: WorkspaceId,
13015 provider: AgentId,
13016 mode: SessionMode,
13017 terminal_size: gate4agent_types::TerminalSize,
13018 initial_prompt: Option<String>,
13019 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13020 bundle: Option<ResolvedBundleReceipt>,
13021 context: Option<ResolvedContextPackReceipt>,
13022 managed_authority: Option<ManagedWorktreeLeaseId>,
13023 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13024 record_policy: SpawnRecordPolicy,
13025 deadline: Option<Instant>,
13026 required_capabilities: &[ProviderRuntimeCapability],
13027 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13028 approval_level: ApprovalLevel,
13029 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13030 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13036 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13037 let reserved_lease = self.managed_worktrees.lock()
13038 .unwrap_or_else(|poisoned| poisoned.into_inner())
13039 .lease_for_workspace(&workspace_id);
13040 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13041 (Some(expected), Some(provided)) if expected == provided => {}
13042 (None, None) => {}
13043 _ => {
13044 return Err(failure(
13045 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13046 "spawn lacks exact managed worktree lease authority",
13047 ));
13048 }
13049 }
13050 self.ensure_workspace_environment_profile_compatible(
13054 &workspace_id,
13055 environment_profile.as_ref(),
13056 )?;
13057 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13058 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13059 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13060 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13061 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13062 };
13063 let transport = match mode {
13069 SessionMode::Pty => TransportKind::Pty,
13070 SessionMode::Inline => TransportKind::Pipe,
13071 SessionMode::Acp => TransportKind::Acp,
13072 };
13073 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13074 let acp_transport = self.provider_supports_acp_transport(&provider);
13075 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13076 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13077 NodeFailureCode::UnsupportedSpawnCapability,
13078 "pre-admitted provider runtime no longer satisfies spawn requirements",
13079 ))?;
13080 runtime_policy
13081 } else if let Some(deadline) = deadline {
13082 let remaining = spawn_deadline_remaining(deadline)?;
13083 timeout(
13084 remaining,
13085 self.admit_provider_runtime(&provider, runtime_requirement),
13086 )
13087 .await
13088 .map_err(|_| {
13089 failure(
13090 NodeFailureCode::SpawnDeadlineExceeded,
13091 "provider admission exceeded the spawn deadline",
13092 )
13093 })??
13094 } else {
13095 self.admit_provider_runtime(&provider, runtime_requirement).await?
13096 };
13097 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13098 let runtime_policy =
13110 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13111 if required_capabilities
13112 .iter()
13113 .any(|capability| !runtime_policy.admits(*capability))
13114 {
13115 return Err(failure(
13116 NodeFailureCode::UnsupportedSpawnCapability,
13117 "provider runtime does not admit a required spawn capability",
13118 ));
13119 }
13120 if let Some(deadline) = deadline {
13121 spawn_deadline_remaining(deadline)?;
13122 }
13123 self.ensure_binding_capacity()?;
13124 let mut working_directory = self.workspace_root(&workspace_id)?;
13125 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13126 let session = SessionKey {
13127 instance_id,
13128 generation: SessionGeneration(1),
13129 };
13130 let address = SessionAddress {
13131 workspace_id: workspace_id.clone(),
13132 session,
13133 };
13134 tracing::info!(
13142 node_id = %self.node_id,
13143 workspace_id = %workspace_id,
13144 provider = %provider,
13145 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13146 session = ?address.session,
13147 "session spawn request accepted",
13148 );
13149 let mut prepared_spawn = SpawnPreparer::prepare(
13150 self,
13151 SpawnPrepareRequest {
13152 instance_id,
13153 address: &address,
13154 provider: &provider,
13155 mode,
13156 environment_profile: environment_profile.as_ref(),
13157 bundle: bundle.as_ref(),
13158 context: context.as_ref(),
13159 managed_authority: managed_authority.as_ref(),
13160 harness_mcp,
13161 approval_level,
13162 network_allowlist: network_allowlist.as_ref(),
13163 browser_profile_id,
13164 },
13165 )?;
13166 let materialization_id = prepared_spawn.materialization_id.take();
13167 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13168 let record_id = self.bind_spawn_session_with_materialization(
13169 &address,
13170 provider.clone(),
13171 mode,
13172 runtime_policy,
13173 record_policy,
13174 environment_profile,
13175 bundle,
13176 context,
13177 materialization_id,
13178 )?;
13179 prepared_spawn.retain_session_guards();
13180 let session_provider = provider.clone();
13184 let agent_id = provider;
13185 let register_command_id = match self.dispatch_bounded(
13186 ControlCommand::Register {
13187 instance_id,
13188 agent_id,
13189 transport,
13190 },
13191 dispatch_timeout,
13192 )
13193 .await {
13194 Ok(command_id) => command_id,
13195 Err(error) => {
13196 if let Some(binding) = self.remove_binding(&address) {
13197 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13198 }
13199 if let Some(record_id) = record_id.as_ref() {
13200 self.discard_record(record_id)?;
13201 }
13202 return Err(spawn_dispatch_error(error, deadline));
13203 }
13204 };
13205 let start_timeout = match spawn_dispatch_timeout(deadline) {
13206 Ok(timeout) => timeout,
13207 Err(error) => {
13208 let recovery = self
13209 .rollback_spawn(
13210 &address,
13211 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13212 )
13213 .await;
13214 return match recovery {
13215 Ok(()) => {
13216 if let Some(record_id) = record_id.as_ref() {
13217 self.discard_record(record_id)?;
13218 }
13219 Err(error)
13220 }
13221 Err(recovery_error) => {
13222 if let Some(record_id) = record_id.as_ref() {
13223 self.mark_record_error(
13224 record_id,
13225 "spawn deadline elapsed and registration recovery failed",
13226 )?;
13227 }
13228 Err(recovery_error)
13229 }
13230 };
13231 }
13232 };
13233 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13234 if let Err(recovery_error) = self
13235 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13236 .await
13237 {
13238 return Err(recovery_error);
13239 }
13240 if let Some(record_id) = record_id.as_ref() {
13241 self.discard_record(record_id)?;
13242 }
13243 return Err(start_error);
13244 }
13245 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13247 working_directory = dir.to_string_lossy().into_owned();
13248 }
13249 let start_result = self
13250 .dispatch_bounded(
13251 ControlCommand::Start {
13252 instance_id,
13253 runtime_policy,
13254 request: StartRequest {
13255 working_directory,
13256 terminal_size,
13257 initial_prompt,
13258 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13259 approval_level,
13260 },
13261 },
13262 start_timeout,
13263 )
13264 .await;
13265 let start_command_id = match start_result {
13266 Ok(command_id) => command_id,
13267 Err(start_error) => {
13268 let start_error = spawn_dispatch_error(start_error, deadline);
13269 let recovery = self
13270 .rollback_spawn(
13271 &address,
13272 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13273 )
13274 .await;
13275 return match recovery {
13276 Ok(()) => {
13277 if let Some(record_id) = record_id.as_ref() {
13278 self.discard_record(record_id)?;
13279 }
13280 Err(start_error)
13281 }
13282 Err(recovery_error) => {
13283 let diagnostic = format!(
13284 "start failed and registration recovery failed: {}",
13285 recovery_error.message,
13286 );
13287 if let Some(record_id) = record_id.as_ref() {
13288 self.mark_record_error(record_id, &diagnostic)?;
13289 }
13290 Err(failure(recovery_error.code, &diagnostic))
13291 }
13292 };
13293 }
13294 };
13295
13296 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13297 loop {
13298 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13307 .or_else(|| self.take_rejected_command_reason(start_command_id))
13308 {
13309 self.rollback_spawn(
13310 &address,
13311 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13312 ).await.map_err(|error| {
13313 failure(
13314 error.code,
13315 &format!(
13316 "spawn was rejected ({}) and registration recovery failed: {}",
13317 reason.message, error.message
13318 ),
13319 )
13320 })?;
13321 if let Some(record_id) = record_id.as_ref() {
13322 self.discard_record(record_id)?;
13323 }
13324 let code = if reason.unsupported_transport {
13325 NodeFailureCode::UnsupportedTransport
13326 } else {
13327 NodeFailureCode::BackendOperationFailed
13328 };
13329 return Err(failure(code, &reason.message));
13330 }
13331 if let Some(current) = self
13332 .handle
13333 .snapshot()
13334 .sessions
13335 .iter()
13336 .find(|current| current.instance_id == instance_id)
13337 {
13338 if current.generation != session.generation {
13339 let divergence = failure(
13340 NodeFailureCode::StaleGeneration,
13341 "spawn generation diverged",
13342 );
13343 return match self
13344 .rollback_spawn(
13345 &address,
13346 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13347 )
13348 .await
13349 {
13350 Ok(()) => {
13351 if let Some(record_id) = record_id.as_ref() {
13352 self.discard_record(record_id)?;
13353 }
13354 Err(divergence)
13355 }
13356 Err(recovery_error) => {
13357 if let Some(record_id) = record_id.as_ref() {
13358 self.mark_record_error(
13359 record_id,
13360 "spawn generation diverged and recovery failed",
13361 )?;
13362 }
13363 Err(recovery_error)
13364 }
13365 };
13366 }
13367 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13368 prepared_spawn.retain_launch_guards();
13369 return Ok((address, runtime_policy));
13370 }
13371 }
13372 if Instant::now() >= commit_deadline {
13373 self.rollback_spawn(
13374 &address,
13375 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13376 ).await.map_err(|error| {
13377 failure(
13378 error.code,
13379 &format!(
13380 "start did not commit and registration recovery failed: {}",
13381 error.message
13382 ),
13383 )
13384 })?;
13385 if let Some(record_id) = record_id.as_ref() {
13386 self.discard_record(record_id)?;
13387 }
13388 return Err(failure(
13389 if deadline.is_some() {
13390 NodeFailureCode::SpawnDeadlineExceeded
13391 } else {
13392 NodeFailureCode::BackendBusy
13393 },
13394 "start did not commit before the bounded deadline; registration was removed",
13395 ));
13396 }
13397 sleep(Duration::from_millis(2)).await;
13398 }
13399 }
13400
13401 fn effective_spawn_runtime_policy(
13402 &self,
13403 runtime_policy: ProviderRuntimePolicy,
13404 ) -> ProviderRuntimePolicy {
13405 #[cfg(feature = "fixture")]
13406 let runtime_policy = if self.fixture_semantic_hook_policy {
13407 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13408 .expect("monitoring Hook fixture policy is internally valid")
13409 } else {
13410 runtime_policy
13411 };
13412 runtime_policy
13413 }
13414
13415 fn verify_harness_mcp_before_start(
13416 &self,
13417 prepared: Option<&PreparedHarnessMcpSpawn>,
13418 ) -> Result<(), NodeFailure> {
13419 prepared
13420 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13421 .transpose()
13422 .map(|_| ())
13423 .map_err(harness_mcp_failure)
13424 }
13425
13426 async fn rollback_spawn(
13427 &self,
13428 address: &SessionAddress,
13429 commit_timeout: Duration,
13430 ) -> Result<(), NodeFailure> {
13431 self.dispatch_bounded(
13432 ControlCommand::Remove {
13433 instance_id: address.session.instance_id,
13434 },
13435 commit_timeout,
13436 )
13437 .await?;
13438 self.wait_until_removed(address, commit_timeout).await?;
13439 if let Some(binding) = self.remove_binding(address) {
13440 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13441 }
13442 Ok(())
13443 }
13444
13445 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13446 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13447 self.dispatch_bounded(
13448 ControlCommand::Remove {
13449 instance_id: address.session.instance_id,
13450 },
13451 commit_timeout,
13452 )
13453 .await?;
13454 self.wait_until_removed(address, commit_timeout).await?;
13455 let removed = self.remove_binding(address);
13456 if let Some(binding) = removed.as_ref() {
13457 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13458 }
13459 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13460 let snapshot = self.managed_worktrees.lock()
13461 .unwrap_or_else(|poisoned| poisoned.into_inner())
13462 .release_session(
13463 &lease_id,
13464 self.incarnation_id,
13465 address.session.instance_id,
13466 address.session.generation,
13467 unix_time_ms(),
13468 );
13469 if self.persist_state().is_err() {
13470 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13471 }
13472 if let Some(snapshot) = snapshot {
13473 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13474 }
13475 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13476 }
13477 Ok(())
13478 }
13479
13480 async fn wait_until_removed(
13526 &self,
13527 address: &SessionAddress,
13528 commit_timeout: Duration,
13529 ) -> Result<(), NodeFailure> {
13530 let deadline = Instant::now() + commit_timeout;
13531 let mut next_dispatch = Instant::now();
13532 loop {
13533 let snapshot = self.handle.snapshot();
13534 let status = snapshot
13535 .sessions
13536 .iter()
13537 .find(|session| session.instance_id == address.session.instance_id)
13538 .map(|session| session.status.clone());
13539 let Some(status) = status else {
13540 return Ok(());
13541 };
13542 let now = Instant::now();
13543 if now >= deadline {
13544 return Err(failure(
13545 NodeFailureCode::BackendBusy,
13546 "remove did not commit before the bounded deadline; session binding was retained",
13547 ));
13548 }
13549 if status.allows_remove() && now >= next_dispatch {
13550 let _ = self.dispatch(ControlCommand::Remove {
13551 instance_id: address.session.instance_id,
13552 });
13553 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13554 }
13555 sleep(Duration::from_millis(2)).await;
13556 }
13557 }
13558
13559 fn resync(&self, after_sequence: u64) -> NodeResponse {
13560 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13561 let event_sequence = history.last_sequence;
13562 let oldest_available_sequence = history.replay_floor_sequence;
13563 let events = history
13564 .events
13565 .iter()
13566 .filter(|event| event.sequence > after_sequence)
13567 .cloned()
13568 .collect();
13569 drop(history);
13570 NodeResponse::Resync {
13571 event_sequence,
13572 oldest_available_sequence,
13573 snapshot: self.snapshot(),
13574 events,
13575 }
13576 }
13577}
13578
13579fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13584 match kind {
13585 AgentStreamChunkKindV1::Text { .. } => "text",
13586 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13587 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13588 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13589 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13590 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13591 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13592 }
13593}
13594
13595async fn accept_connections(
13609 endpoint: &str,
13610 call_home: Option<std::net::SocketAddr>,
13611 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13612 shared: Arc<NodeShared>,
13613) -> Result<(), NodeServerError> {
13614 let mut connections = JoinSet::new();
13615 let result = match call_home {
13616 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13617 Some(relay) => {
13618 let listener =
13619 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13620 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13621 tokio::pin!(listener);
13622 tokio::pin!(dialler);
13623 tokio::select! {
13624 result = &mut listener => result,
13625 result = &mut dialler => result,
13626 }
13627 }
13628 };
13629 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13630 while !connections.is_empty() {
13631 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13632 if remaining.is_zero()
13633 || timeout(remaining, connections.join_next()).await.is_err()
13634 {
13635 break;
13636 }
13637 }
13638 connections.abort_all();
13639 while connections.join_next().await.is_some() {}
13640 result
13641}
13642
13643const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13647
13648async fn call_home_loop(
13662 relay: std::net::SocketAddr,
13663 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13664 shared: Arc<NodeShared>,
13665) -> Result<(), NodeServerError> {
13666 let mut failures = 0_usize;
13667 loop {
13668 if shared.shutdown.load(Ordering::Acquire) {
13669 return Ok(());
13670 }
13671 let permit = tokio::select! {
13672 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13673 permit.map_err(|_| NodeServerError::PreauthClosed)?
13674 }
13675 _ = shared.shutdown_notify.notified() => return Ok(()),
13676 };
13677 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13678 Ok(()) => {
13679 failures = 0;
13683 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13684 }
13685 Err(error) => {
13686 failures = failures.saturating_add(1);
13687 tracing::warn!(
13688 node_id = %shared.node_id,
13689 %relay,
13690 cause = %error,
13691 attempt = failures,
13692 "call-home connection failed",
13693 );
13694 let backoff = call_home_backoff(failures);
13695 tokio::select! {
13696 _ = sleep(backoff) => {}
13697 _ = shared.shutdown_notify.notified() => return Ok(()),
13698 }
13699 }
13700 }
13701 }
13702}
13703
13704const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13706const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13708
13709fn call_home_keepalive() -> socket2::TcpKeepalive {
13710 let keepalive = socket2::TcpKeepalive::new()
13711 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13712 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13713 #[cfg(not(windows))]
13714 let keepalive = keepalive.with_retries(3);
13715 keepalive
13716}
13717
13718fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13719 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13720}
13721
13722pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13724 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13725}
13726
13727fn call_home_backoff(attempt: usize) -> Duration {
13729 Duration::from_millis(
13730 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13731 )
13732}
13733
13734
13735async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13738 #[cfg(feature = "wireguard")]
13739 {
13740 let std_stream = tokio::task::spawn_blocking(move || {
13741 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13742 })
13743 .await
13744 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13745 .map_err(NodeServerError::CallHomeTransport)?;
13746 std_stream
13747 .set_nonblocking(true)
13748 .map_err(NodeServerError::CallHomeTransport)?;
13749 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13750 }
13751 #[cfg(not(feature = "wireguard"))]
13752 {
13753 tokio::net::TcpStream::connect(relay)
13754 .await
13755 .map_err(NodeServerError::CallHomeTransport)
13756 }
13757}
13758
13759async fn dial_relay_and_serve(
13761 relay: std::net::SocketAddr,
13762 identity: Option<&hatchery_enroll::NodeIdentity>,
13763 shared: Arc<NodeShared>,
13764 preauth_permit: OwnedSemaphorePermit,
13765) -> Result<(), NodeServerError> {
13766 let mut stream = dial_call_home(relay).await?;
13767 let _ = stream.set_nodelay(true);
13771 if let Err(error) = enable_call_home_keepalive(&stream) {
13777 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13778 }
13779 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13780 .await
13781 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13782 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13783 let mut session_token = None;
13784 if let Some(identity) = identity {
13785 let mut magic = [0_u8; 4];
13789 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13790 .await
13791 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13792 .map_err(NodeServerError::CallHomeTransport)?;
13793 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13794 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13795 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13796 .await
13797 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13798 .map_err(NodeServerError::CallHomeTransport)?;
13799 let (answer, token) = identity
13800 .answer_challenge(shared.node_id.as_str(), &frame)
13801 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13802 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13803 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13804 session_token = Some(token);
13805 }
13806 }
13807 serve_connection(stream, shared, session_token, preauth_permit).await
13808}
13809
13810async fn accept_connections_inner(
13811 endpoint: &str,
13812 shared: Arc<NodeShared>,
13813 connections: &mut JoinSet<Result<(), NodeServerError>>,
13814) -> Result<(), NodeServerError> {
13815 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13816 loop {
13817 while connections.try_join_next().is_some() {}
13818 if shared.shutdown.load(Ordering::Acquire) {
13819 return Ok(());
13820 }
13821 let preauth_permit = tokio::select! {
13822 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13823 permit.map_err(|_| NodeServerError::PreauthClosed)?
13824 }
13825 _ = shared.shutdown_notify.notified() => return Ok(()),
13826 };
13827 let server = tokio::select! {
13828 result = listener.accept() => result?,
13829 _ = shared.shutdown_notify.notified() => return Ok(()),
13830 };
13831 let connection_shared = Arc::clone(&shared);
13832 connections.spawn(async move {
13833 serve_connection(server, connection_shared, None, preauth_permit).await
13834 });
13835 if shared.shutdown.load(Ordering::Acquire) {
13836 return Ok(());
13837 }
13838 }
13839}
13840
13841async fn serve_connection<S>(
13849 mut pipe: S,
13850 shared: Arc<NodeShared>,
13851 session_token: Option<String>,
13852 preauth_permit: OwnedSemaphorePermit,
13853) -> Result<(), NodeServerError>
13854where
13855 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13856{
13857 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13859 let hello_frame = timeout(
13860 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13861 read_json_frame_limited_body_timeout(
13862 &mut pipe,
13863 MAX_NODE_HELLO_FRAME_BYTES,
13864 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13865 ),
13866 )
13867 .await
13868 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13869 let ClientFrame::Hello(hello) = hello_frame else {
13870 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13871 };
13872 if hello.build_stamp != BUILD_STAMP {
13873 return Err(NodeServerError::Handshake(format!(
13874 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13875 hello.build_stamp,
13876 )));
13877 }
13878 let compatibility = match hello.compatibility.as_ref() {
13879 Some(offer) => {
13880 let mut selected = node_compatibility_support(&shared)?
13881 .negotiate(offer)
13882 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13883 project_negotiated_provider_ids(&mut selected);
13884 Some(selected)
13885 }
13886 None => None,
13887 };
13888 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13889 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13890 (Some(offer), Some(selected)) => negotiated_auth_proof(
13891 auth_token,
13892 AuthDirection::Server,
13893 hello.role,
13894 &hello.client_nonce,
13895 &server_nonce,
13896 offer,
13897 selected,
13898 ),
13899 (None, None) => auth_proof(
13900 auth_token,
13901 AuthDirection::Server,
13902 hello.role,
13903 &hello.client_nonce,
13904 &server_nonce,
13905 ),
13906 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13907 }
13908 .map_err(NodeServerError::Authentication)?;
13909 write_json_frame_limited(
13910 &mut pipe,
13911 &ServerFrame::Challenge(ServerChallenge {
13912 build_stamp: BUILD_STAMP.to_owned(),
13913 server_nonce,
13914 server_proof,
13915 compatibility: compatibility.clone(),
13916 }),
13917 MAX_NODE_HELLO_FRAME_BYTES,
13918 )
13919 .await?;
13920 let authentication = timeout(
13921 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13922 read_json_frame_limited_body_timeout(
13923 &mut pipe,
13924 MAX_NODE_HELLO_FRAME_BYTES,
13925 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13926 ),
13927 )
13928 .await
13929 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13930 let ClientFrame::Authenticate(authentication) = authentication else {
13931 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13932 };
13933 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13934 (Some(offer), Some(selected)) => negotiated_auth_proof(
13935 auth_token,
13936 AuthDirection::Client,
13937 hello.role,
13938 &hello.client_nonce,
13939 &server_nonce,
13940 offer,
13941 selected,
13942 ),
13943 (None, None) => auth_proof(
13944 auth_token,
13945 AuthDirection::Client,
13946 hello.role,
13947 &hello.client_nonce,
13948 &server_nonce,
13949 ),
13950 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13951 }
13952 .map_err(NodeServerError::Authentication)?;
13953 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13954 return Err(NodeServerError::Handshake("access denied".to_owned()));
13955 }
13956 let selected_capabilities = compatibility
13957 .as_ref()
13958 .map(|selected| selected.capabilities.clone())
13959 .unwrap_or_default();
13960 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13961 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13962 });
13963 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13964 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13965 });
13966 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13967 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13968 });
13969 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13970 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13971 });
13972 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13973 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13974 });
13975 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13976 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13977 }) && selected_capabilities.iter().any(|capability| {
13978 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13979 });
13980 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13981 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13982 });
13983 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13984 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13985 });
13986 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13987 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13988 });
13989 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13990 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13991 });
13992 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13993 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13994 });
13995 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13996 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13997 });
13998 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13999 .try_acquire_owned()
14000 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14001 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14002 let _release_guard = ControllerReleaseGuard {
14003 shared: Arc::clone(&shared),
14004 connection_id,
14005 };
14006 drop(preauth_permit);
14007 let _authenticated_permit = authenticated_permit;
14008 let mut event_rx = shared.event_tx.subscribe();
14009 let mut terminal_event_rx = terminal_event_subscription(
14010 &shared,
14011 include_terminal_frame_events,
14012 );
14013 let mut agent_stream_event_rx = agent_stream_event_subscription(
14014 &shared,
14015 include_agent_stream_events,
14016 );
14017 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14018 &shared,
14019 include_harness_mcp_proxy,
14020 );
14021 write_json_frame(
14022 &mut pipe,
14023 &ServerFrame::Hello(NodeHello {
14024 build_stamp: BUILD_STAMP.to_owned(),
14025 incarnation_id: shared.incarnation_id,
14026 connection_id,
14027 role: hello.role,
14028 event_sequence: shared.current_sequence(),
14029 controller: shared.controller_state(),
14030 snapshot: snapshot_for_wire(
14031 &shared,
14032 include_provider_runtime_status,
14033 include_open_provider_ids,
14034 include_spawn_profiles,
14035 include_managed_worktrees,
14036 include_child_environment_profiles,
14037 include_session_bundles,
14038 include_history_context_packs,
14039 include_agent_progress,
14040 include_session_task_correlation,
14041 ),
14042 compatibility,
14043 }),
14044 )
14045 .await?;
14046 let (mut reader, mut writer) = tokio::io::split(pipe);
14047 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14048 let reader_task = tokio::spawn(async move {
14049 loop {
14050 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14051 &mut reader,
14052 MAX_NODE_CLIENT_FRAME_BYTES,
14053 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14054 )
14055 .await;
14056 let terminal = frame.is_err();
14057 if frame_tx.send(frame).await.is_err() || terminal {
14058 break;
14059 }
14060 }
14061 });
14062 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14063 let mut pending_events = Vec::new();
14064 let mut resync_required = false;
14065 let mut discard_events_through = 0_u64;
14066 loop {
14067 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14072 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14073 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14074 for _ in 0..durable_drain_budget {
14075 match event_rx.try_recv() {
14076 Ok(event) => queue_connection_event(
14077 &mut pending_events,
14078 event,
14079 discard_events_through,
14080 ),
14081 Err(broadcast::error::TryRecvError::Empty) => break,
14082 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14083 resync_required = true;
14084 break;
14085 }
14086 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14087 }
14088 }
14089 if let Some(receiver) = terminal_event_rx.as_mut() {
14090 match receiver.try_recv() {
14091 Ok(events) => queue_connection_event_batch(
14092 &mut pending_events,
14093 &events,
14094 discard_events_through,
14095 ),
14096 Err(broadcast::error::TryRecvError::Empty) => {}
14097 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14098 resync_required = true;
14099 }
14100 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14101 }
14102 }
14103 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14104 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14114 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14115 for _ in 0..agent_stream_drain_budget {
14116 match receiver.try_recv() {
14117 Ok(events) => queue_connection_event_batch(
14118 &mut pending_events,
14119 &events,
14120 discard_events_through,
14121 ),
14122 Err(broadcast::error::TryRecvError::Empty) => break,
14123 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14124 tracing::warn!(
14125 connection_id,
14126 dropped,
14127 "agent stream event subscription lagged; forcing a resync",
14128 );
14129 resync_required = true;
14130 break;
14131 }
14132 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14133 }
14134 }
14135 }
14136 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14137 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14143 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14144 for _ in 0..harness_mcp_proxy_drain_budget {
14145 match receiver.try_recv() {
14146 Ok(events) => queue_connection_event_batch(
14147 &mut pending_events,
14148 &events,
14149 discard_events_through,
14150 ),
14151 Err(broadcast::error::TryRecvError::Empty) => break,
14152 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14153 tracing::warn!(
14154 connection_id,
14155 dropped,
14156 "harness MCP proxy event subscription lagged; forcing a resync",
14157 );
14158 resync_required = true;
14159 break;
14160 }
14161 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14162 }
14163 }
14164 }
14165 if resync_required {
14166 let event = resync_required_event(&shared);
14167 apply_connection_resync_watermark(
14168 &mut pending_events,
14169 &mut discard_events_through,
14170 event.sequence,
14171 );
14172 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14173 resync_required = false;
14174 continue;
14175 }
14176 if !pending_events.is_empty() {
14177 pending_events.sort_unstable_by_key(|event| event.sequence);
14178 let burst_len = connection_event_burst_len(pending_events.len());
14179 shared
14180 .connection_events_sent
14181 .fetch_add(burst_len as u64, Ordering::Relaxed);
14182 for event in pending_events.drain(..burst_len) {
14183 let event = if include_managed_worktrees {
14184 Some(event)
14185 } else {
14186 project_event_without_managed_worktrees(event)
14187 };
14188 let event = event.and_then(|event| if include_open_provider_ids {
14189 Some(event)
14190 } else {
14191 project_event_legacy_provider_ids(&shared, event)
14192 });
14193 let event = event.map(|event| if include_child_environment_profiles {
14194 event
14195 } else {
14196 project_event_without_child_environment_profile(event)
14197 });
14198 let event = event.map(|event| if include_session_bundles {
14199 event
14200 } else {
14201 project_event_without_session_bundle(event)
14202 });
14203 let event = event.map(|event| {
14204 project_event_history_for_wire(event, include_history_context_packs)
14205 });
14206 let event = event.map(|event| if include_history_context_packs {
14207 event
14208 } else {
14209 project_event_without_context_pack(event)
14210 });
14211 let event = event.map(|event| if include_session_task_correlation {
14212 event
14213 } else {
14214 project_event_without_session_task_binding(event)
14215 });
14216 let exact_harness_controller = hello.role == ClientRole::Operator
14217 && shared.controller_state().is_some_and(|controller| {
14218 controller.connection_id == connection_id
14219 });
14220 let event = event.and_then(|event| {
14221 if include_harness_mcp_proxy && exact_harness_controller {
14222 Some(event)
14223 } else {
14224 project_event_without_harness_mcp_proxy(event)
14225 }
14226 });
14227 if let Some(event) = event {
14228 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14229 }
14230 }
14231 }
14232 tokio::select! {
14233 biased;
14234 frame = frame_rx.recv() => {
14235 let frame = match frame {
14236 Some(frame) => frame,
14237 None => break,
14238 };
14239 let frame = match frame {
14240 Ok(frame) => frame,
14241 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14242 Err(error) => return Err(error.into()),
14243 };
14244 let ClientFrame::Request(request) = frame else {
14245 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14246 };
14247 #[cfg(feature = "fixture")]
14248 let probe_spawn_managed_worktree_v2 = matches!(
14249 &request.request,
14250 NodeRequest::SpawnManagedWorktreeV2 { .. }
14251 );
14252 let requires_open_provider_ids =
14253 request_requires_open_provider_ids(&shared, &request.request);
14254 let requires_child_environment_profile =
14255 request_requires_child_environment_profile(&shared, &request.request);
14256 let requires_session_bundle =
14257 request_requires_session_bundle(&shared, &request.request);
14258 let requires_history_context_pack =
14259 request_requires_history_context_pack(&shared, &request.request);
14260 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14261 ResponseEnvelope {
14262 request_id: request.request_id,
14263 result: Err(failure(
14264 NodeFailureCode::UnsupportedCapability,
14265 "open provider IDs were not negotiated",
14266 )),
14267 }
14268 } else if requires_child_environment_profile
14269 && !include_child_environment_profiles
14270 {
14271 ResponseEnvelope {
14272 request_id: request.request_id,
14273 result: Err(failure(
14274 NodeFailureCode::UnsupportedCapability,
14275 "child environment profiles were not negotiated",
14276 )),
14277 }
14278 } else if requires_session_bundle && !include_session_bundles {
14279 ResponseEnvelope {
14280 request_id: request.request_id,
14281 result: Err(failure(
14282 NodeFailureCode::UnsupportedCapability,
14283 "session bundles were not negotiated",
14284 )),
14285 }
14286 } else if requires_history_context_pack && !include_history_context_packs {
14287 ResponseEnvelope {
14288 request_id: request.request_id,
14289 result: Err(failure(
14290 NodeFailureCode::UnsupportedCapability,
14291 "history context packs were not negotiated",
14292 )),
14293 }
14294 } else if request_uses_unnegotiated_capability(
14295 &request.request,
14296 &selected_capabilities,
14297 )
14298 {
14299 ResponseEnvelope {
14300 request_id: request.request_id,
14301 result: Err(failure(
14302 NodeFailureCode::UnsupportedCapability,
14303 "request capability was not negotiated",
14304 )),
14305 }
14306 } else {
14307 process_request(&shared, connection_id, hello.role, request).await
14308 };
14309 #[cfg(feature = "fixture")]
14310 if probe_spawn_managed_worktree_v2 {
14311 shared
14312 .fixture_spawn_managed_worktree_v2_failure_probe
14313 .record(reply.result.as_ref().err().map(|failure| failure.code));
14314 }
14315 if !include_provider_runtime_status {
14316 clear_response_provider_runtime_status(&mut reply);
14317 }
14318 if !include_managed_worktrees {
14319 project_response_without_managed_worktrees(&mut reply);
14320 }
14321 if !include_open_provider_ids {
14322 project_response_legacy_provider_ids(&shared, &mut reply);
14323 }
14324 if !include_child_environment_profiles {
14325 project_response_without_child_environment_profile(&mut reply);
14326 }
14327 if !include_session_bundles {
14328 project_response_without_session_bundle(&mut reply);
14329 }
14330 project_response_history_for_wire(
14331 &mut reply,
14332 include_history_context_packs,
14333 );
14334 if !include_history_context_packs {
14335 project_response_without_context_pack(&mut reply);
14336 }
14337 if !include_agent_progress {
14338 project_response_without_agent_progress(&mut reply);
14339 }
14340 if !include_session_task_correlation {
14341 project_response_without_session_task_binding(&mut reply);
14342 }
14343 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14344 }
14345 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14346 event = event_rx.recv() => {
14347 match event {
14348 Ok(event) => queue_connection_event(
14349 &mut pending_events,
14350 event,
14351 discard_events_through,
14352 ),
14353 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14354 Err(broadcast::error::RecvError::Closed) => break,
14355 }
14356 }
14357 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14358 match events {
14359 Ok(events) => queue_connection_event_batch(
14360 &mut pending_events,
14361 &events,
14362 discard_events_through,
14363 ),
14364 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14365 Err(broadcast::error::RecvError::Closed) => break,
14366 }
14367 }
14368 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14369 match events {
14370 Ok(events) => queue_connection_event_batch(
14371 &mut pending_events,
14372 &events,
14373 discard_events_through,
14374 ),
14375 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14376 Err(broadcast::error::RecvError::Closed) => break,
14377 }
14378 }
14379 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14380 match events {
14381 Ok(events) => queue_connection_event_batch(
14382 &mut pending_events,
14383 &events,
14384 discard_events_through,
14385 ),
14386 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14387 Err(broadcast::error::RecvError::Closed) => break,
14388 }
14389 }
14390 _ = shared.shutdown_notify.notified() => break,
14391 }
14392 }
14393 Ok(())
14394}
14395
14396async fn receive_terminal_event_batch(
14397 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14398) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14399 match receiver {
14400 Some(receiver) => receiver.recv().await,
14401 None => std::future::pending().await,
14402 }
14403}
14404
14405fn terminal_event_subscription(
14406 shared: &NodeShared,
14407 enabled: bool,
14408) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14409 enabled.then(|| shared.terminal_event_tx.subscribe())
14410}
14411
14412fn agent_stream_event_subscription(
14416 shared: &NodeShared,
14417 enabled: bool,
14418) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14419 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14420}
14421
14422fn harness_mcp_proxy_event_subscription(
14427 shared: &NodeShared,
14428 enabled: bool,
14429) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14430 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14431}
14432
14433fn queue_connection_event(
14434 pending: &mut Vec<NodeEventEnvelope>,
14435 event: NodeEventEnvelope,
14436 discard_through: u64,
14437) {
14438 if event.sequence <= discard_through
14439 && !event.event.requires_harness_mcp_proxy_capability()
14440 {
14441 return;
14442 }
14443 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14444 if let Some(index) = pending.iter().position(|current| {
14445 matches!(
14446 ¤t.event,
14447 NodeEvent::TerminalFrame {
14448 address: current_address,
14449 ..
14450 } if current_address == address
14451 )
14452 }) {
14453 pending.remove(index);
14454 }
14455 }
14456 pending.push(event);
14457}
14458
14459fn project_event_without_harness_mcp_proxy(
14460 envelope: NodeEventEnvelope,
14461) -> Option<NodeEventEnvelope> {
14462 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14463}
14464
14465fn queue_connection_event_batch(
14466 pending: &mut Vec<NodeEventEnvelope>,
14467 events: &[NodeEventEnvelope],
14468 discard_through: u64,
14469) {
14470 for event in events {
14471 queue_connection_event(pending, event.clone(), discard_through);
14472 }
14473}
14474
14475fn apply_connection_resync_watermark(
14476 pending: &mut Vec<NodeEventEnvelope>,
14477 discard_through: &mut u64,
14478 marker_sequence: u64,
14479) {
14480 *discard_through = (*discard_through).max(marker_sequence);
14481 pending.retain(|event| event.sequence > *discard_through);
14482}
14483
14484fn connection_event_burst_len(pending_len: usize) -> usize {
14485 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14486}
14487
14488fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14489 let history = shared
14490 .history
14491 .lock()
14492 .unwrap_or_else(|poisoned| poisoned.into_inner());
14493 let sequence = history.last_sequence;
14494 let oldest_available_sequence = history.replay_floor_sequence;
14495 NodeEventEnvelope {
14496 sequence,
14497 event: NodeEvent::ResyncRequired {
14498 oldest_available_sequence,
14499 },
14500 }
14501}
14502
14503fn node_compatibility_support(
14504 shared: &NodeShared,
14505) -> Result<NodeCompatibilitySupport, NodeServerError> {
14506 let mut support = node_compatibility_support_for_manifest(
14507 &shared.provider_contracts,
14508 &shared.provider_adapter_contracts,
14509 )?;
14510 if shared
14511 .delivery_store
14512 .lock()
14513 .unwrap_or_else(|poisoned| poisoned.into_inner())
14514 .is_some()
14515 {
14516 support.capabilities.push(
14517 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14518 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14519 );
14520 }
14521 if shared.harness_mcp_registry.is_some() {
14522 support.capabilities.push(
14523 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14524 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525 );
14526 }
14527 Ok(support)
14528}
14529
14530fn node_compatibility_support_for_manifest(
14531 provider_contracts: &[ProviderContractSupport],
14532 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14533) -> Result<NodeCompatibilitySupport, NodeServerError> {
14534 Ok(NodeCompatibilitySupport {
14535 build_stamp: BUILD_STAMP.to_owned(),
14536 capabilities: baseline_capabilities()?,
14537 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14538 path_semantics: platform::path_semantics(),
14539 local_transport: platform::local_transport(),
14540 state_schema: StateSchemaSupport {
14541 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14542 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14543 },
14544 provider_contracts: provider_contracts.to_vec(),
14545 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14546 })
14547}
14548
14549fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14550 let capabilities = [
14551 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14552 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14553 NODE_REPOSITORY_PATH_CAPABILITY,
14554 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14555 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14556 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14557 NODE_GIT_READ_CAPABILITY,
14558 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14559 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14560 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14561 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14562 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14563 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14564 NODE_ACP_CONTROL_CAPABILITY,
14565 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14566 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14567 NODE_WORKTREE_SELECTION_CAPABILITY,
14568 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14569 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14570 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14571 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14572 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14573 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14574 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14575 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14576 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14577 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14578 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14579 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14580 ]
14581 .into_iter()
14582 .map(|capability| {
14583 CapabilityId::new(capability)
14584 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14585 })
14586 .collect::<Result<Vec<_>, _>>()?;
14587 #[cfg(windows)]
14588 let capabilities = {
14589 let mut capabilities = capabilities;
14590 capabilities.push(
14591 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14592 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14593 );
14594 capabilities
14595 };
14596 Ok(capabilities)
14597}
14598
14599fn request_uses_unnegotiated_capability(
14600 request: &NodeRequest,
14601 selected_capabilities: &[CapabilityId],
14602) -> bool {
14603 let selected = |required: &str| {
14604 selected_capabilities
14605 .iter()
14606 .any(|capability| capability.as_str() == required)
14607 };
14608 request
14609 .required_capability()
14610 .is_some_and(|required| !selected(required))
14611 || (request.requires_worktree_selection_capability()
14612 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14613 || (request.requires_spawn_spec_defaults_overrides_capability()
14614 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14615 || (request.requires_spawn_profile_revision_capability()
14616 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14617 || (request.requires_child_environment_profile_capability()
14618 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14619 || (request.requires_session_bundle_materialization_capability()
14620 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14621 || (request.requires_history_context_pack_capability()
14622 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14623}
14624
14625fn snapshot_for_wire(
14626 shared: &NodeShared,
14627 include_provider_runtime_status: bool,
14628 include_open_provider_ids: bool,
14629 include_spawn_profiles: bool,
14630 include_managed_worktrees: bool,
14631 include_child_environment_profiles: bool,
14632 include_session_bundles: bool,
14633 include_history_context_packs: bool,
14634 include_agent_progress: bool,
14635 include_session_task_correlation: bool,
14636) -> NodeSnapshot {
14637 let mut snapshot = shared.snapshot();
14638 if !include_provider_runtime_status {
14639 snapshot.provider_runtime_statuses.clear();
14640 }
14641 if !include_open_provider_ids {
14642 project_snapshot_legacy_provider_ids(&mut snapshot);
14643 }
14644 if !include_managed_worktrees {
14645 snapshot.managed_worktrees.clear();
14646 for workspace in &mut snapshot.workspaces {
14647 workspace.managed_worktree_profiles = None;
14648 }
14649 }
14650 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14651 if !include_spawn_profiles {
14652 inventory.spawn_profiles = None;
14653 }
14654 if !include_session_bundles {
14655 inventory.bundles = None;
14656 }
14657 if inventory.spawn_profiles.is_none()
14658 && inventory.bundles.is_none()
14659 && inventory.network_allowlists.is_none()
14660 {
14661 snapshot.launch_inventory = None;
14662 }
14663 }
14664 if !include_child_environment_profiles {
14665 clear_snapshot_child_environment_profiles(&mut snapshot);
14666 }
14667 if !include_session_bundles {
14668 clear_snapshot_session_bundles(&mut snapshot);
14669 }
14670 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14671 if !include_history_context_packs {
14672 clear_snapshot_context_packs(&mut snapshot);
14673 }
14674 if !include_agent_progress {
14675 snapshot.agent_progress.clear();
14676 }
14677 if !include_session_task_correlation {
14678 clear_snapshot_session_task_bindings(&mut snapshot);
14679 }
14680 snapshot
14681}
14682
14683fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14684 for record in &mut snapshot.session_records {
14685 record.task_binding = None;
14686 }
14687}
14688
14689fn project_event_without_session_task_binding(
14690 mut envelope: NodeEventEnvelope,
14691) -> NodeEventEnvelope {
14692 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14693 record.task_binding = None;
14694 }
14695 envelope
14696}
14697
14698fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14699 let Ok(response) = reply.result.as_mut() else { return };
14700 match response {
14701 NodeResponse::Snapshot { snapshot, .. } => {
14702 clear_snapshot_session_task_bindings(snapshot);
14703 }
14704 NodeResponse::Resync { snapshot, events, .. } => {
14705 clear_snapshot_session_task_bindings(snapshot);
14706 for event in events {
14707 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14708 record.task_binding = None;
14709 }
14710 }
14711 }
14712 NodeResponse::SessionRecordUpdated { record }
14713 | NodeResponse::ProviderSessionIndexed { record }
14714 | NodeResponse::NativeSessionIndexed { record, .. }
14715 | NodeResponse::SessionRecordResumed { record, .. } => {
14716 record.task_binding = None;
14717 }
14718 _ => {}
14719 }
14720}
14721
14722fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14723 let Ok(response) = reply.result.as_mut() else { return };
14724 match response {
14725 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14726 snapshot.agent_progress.clear();
14727 }
14728 _ => {}
14729 }
14730}
14731
14732fn project_event_without_managed_worktrees(
14733 mut envelope: NodeEventEnvelope,
14734) -> Option<NodeEventEnvelope> {
14735 if matches!(
14736 envelope.event,
14737 NodeEvent::ManagedWorktreeUpserted { .. }
14738 | NodeEvent::ManagedWorktreeRemoved { .. }
14739 ) {
14740 return None;
14741 }
14742 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14743 workspace.managed_worktree_profiles = None;
14744 }
14745 Some(envelope)
14746}
14747
14748fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14749 if matches!(
14750 reply.result,
14751 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14752 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14753 ) {
14754 reply.result = Err(failure(
14755 NodeFailureCode::UnsupportedCapability,
14756 "managed worktree capability was not negotiated",
14757 ));
14758 return;
14759 }
14760 let Ok(response) = reply.result.as_mut() else { return };
14761 match response {
14762 NodeResponse::Snapshot { snapshot, .. } => {
14763 snapshot.managed_worktrees.clear();
14764 for workspace in &mut snapshot.workspaces {
14765 workspace.managed_worktree_profiles = None;
14766 }
14767 }
14768 NodeResponse::Resync { snapshot, events, .. } => {
14769 snapshot.managed_worktrees.clear();
14770 for workspace in &mut snapshot.workspaces {
14771 workspace.managed_worktree_profiles = None;
14772 }
14773 *events = std::mem::take(events)
14774 .into_iter()
14775 .filter_map(project_event_without_managed_worktrees)
14776 .collect();
14777 }
14778 NodeResponse::WorkspaceRegistered { workspace }
14779 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14780 | NodeResponse::WorktreeCreated { workspace, .. } => {
14781 workspace.managed_worktree_profiles = None;
14782 }
14783 NodeResponse::WorkspaceInspected { inspection } => {
14784 inspection.git.managed_worktree = None;
14785 }
14786 _ => {}
14787 }
14788}
14789
14790fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14791 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14792 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14793 });
14794 if includes_open_provider_ids {
14795 return;
14796 }
14797 compatibility
14798 .provider_contracts
14799 .retain(|contract| provider_id_is_legacy(&contract.provider));
14800 compatibility
14801 .provider_adapter_contracts
14802 .retain(|contract| provider_id_is_legacy(&contract.provider));
14803}
14804
14805fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14806 if let Some(spec) = match request {
14807 NodeRequest::SpawnSpec { spec }
14808 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14809 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14810 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14811 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14812 _ => None,
14813 } {
14814 return shared
14815 .resolve_spawn_spec(spec)
14816 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14817 }
14818 request_requires_open_provider_ids_with(
14819 request,
14820 |session| shared.validate_address(session).ok(),
14821 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14822 )
14823}
14824
14825fn request_requires_child_environment_profile(
14826 shared: &NodeShared,
14827 request: &NodeRequest,
14828) -> bool {
14829 if let Some(spec) = match request {
14830 NodeRequest::SpawnSpec { spec }
14831 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14832 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14833 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14834 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14835 _ => None,
14836 } {
14837 return shared
14838 .resolve_spawn_spec(spec)
14839 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14840 }
14841 match request {
14842 NodeRequest::Resume { session, .. }
14843 | NodeRequest::Prompt { session, .. }
14844 | NodeRequest::Paste { session, .. }
14845 | NodeRequest::Input { session, .. }
14846 | NodeRequest::TerminalBytes { session, .. }
14847 | NodeRequest::TerminalControl { session, .. }
14848 | NodeRequest::Resize { session, .. }
14849 | NodeRequest::Interrupt { session }
14850 | NodeRequest::Stop { session, .. }
14851 | NodeRequest::Remove { session }
14852 | NodeRequest::ResolveInteraction { session, .. }
14853 | NodeRequest::SetSessionMode { session, .. }
14854 | NodeRequest::SetSessionConfigOption { session, .. }
14855 | NodeRequest::SetSessionModel { session, .. }
14856 | NodeRequest::DiscoverHistory { session, .. }
14857 | NodeRequest::LoadHistory { session, .. }
14858 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14859 | NodeRequest::ExportContextPack { session } => shared
14860 .session_bindings
14861 .lock()
14862 .unwrap_or_else(|poisoned| poisoned.into_inner())
14863 .get(&session.session.instance_id)
14864 .filter(|binding| {
14865 binding.workspace_id == session.workspace_id
14866 && binding.generation == session.session.generation
14867 })
14868 .map_or(true, |binding| binding.environment_profile.is_some()),
14869 NodeRequest::RenameSessionRecord { record_id, .. }
14870 | NodeRequest::SetSessionTask { record_id, .. }
14871 | NodeRequest::ResumeSessionRecord { record_id, .. }
14872 | NodeRequest::ForgetSessionRecord { record_id } => shared
14873 .record(record_id)
14874 .map_or(true, |record| record.environment_profile.is_some()),
14875 NodeRequest::ArmHarnessMcpReservation { .. }
14876 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14877 | NodeRequest::ActivateHarnessMcpReservation { .. }
14878 | NodeRequest::AbortHarnessMcpReservation { .. }
14879 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14880 | NodeRequest::RejectHarnessMcpCall { .. }
14881 | NodeRequest::Snapshot
14882 | NodeRequest::Resync { .. }
14883 | NodeRequest::BeginDeliveryStage { .. }
14884 | NodeRequest::PutDeliveryBlobChunk { .. }
14885 | NodeRequest::CommitDeliveryStage { .. }
14886 | NodeRequest::AbortDeliveryStage { .. }
14887 | NodeRequest::BrowseHostDirectories { .. }
14888 | NodeRequest::InspectWorkspace { .. }
14889 | NodeRequest::ReadWorkspaceFile { .. }
14890 | NodeRequest::WriteWorkspaceFile { .. }
14891 | NodeRequest::CreateWorkspaceFile { .. }
14892 | NodeRequest::CreateWorkspaceDirectory { .. }
14893 | NodeRequest::ReadGitHistory { .. }
14894 | NodeRequest::ReadGitDiff { .. }
14895 | NodeRequest::AcquireController { .. }
14896 | NodeRequest::ReleaseController
14897 | NodeRequest::Spawn { .. }
14898 | NodeRequest::SpawnWithOptions { .. }
14899 | NodeRequest::SpawnSpec { .. }
14900 | NodeRequest::RegisterWorkspace { .. }
14901 | NodeRequest::CreateStandaloneWorkspace { .. }
14902 | NodeRequest::UnregisterWorkspace { .. }
14903 | NodeRequest::CreateWorktree { .. }
14904 | NodeRequest::RemoveWorktree { .. }
14905 | NodeRequest::SpawnManagedWorktree { .. }
14906 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14907 | NodeRequest::CleanupManagedWorktree { .. }
14908 | NodeRequest::IndexProviderSession { .. }
14909 | NodeRequest::IndexNativeSession { .. }
14910 | NodeRequest::CatalogNativeSessions { .. }
14911 | NodeRequest::PageNativeSessions { .. }
14912 | NodeRequest::PreviewNativeSession { .. }
14913 | NodeRequest::PreviewSessionRecord { .. }
14914 | NodeRequest::ForgetContextPack { .. }
14915 | NodeRequest::ResolveDurableContextPack { .. }
14916 | NodeRequest::ReadContextPack { .. }
14917 | NodeRequest::Shutdown => false,
14918 }
14919}
14920
14921fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14922 if let Some(spec) = match request {
14923 NodeRequest::SpawnSpec { spec }
14924 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14925 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14926 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14927 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14928 _ => None,
14929 } {
14930 return shared
14931 .resolve_spawn_spec(spec)
14932 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14933 }
14934 match request {
14935 NodeRequest::Resume { session, .. }
14936 | NodeRequest::Prompt { session, .. }
14937 | NodeRequest::Paste { session, .. }
14938 | NodeRequest::Input { session, .. }
14939 | NodeRequest::TerminalBytes { session, .. }
14940 | NodeRequest::TerminalControl { session, .. }
14941 | NodeRequest::Resize { session, .. }
14942 | NodeRequest::Interrupt { session }
14943 | NodeRequest::Stop { session, .. }
14944 | NodeRequest::Remove { session }
14945 | NodeRequest::ResolveInteraction { session, .. }
14946 | NodeRequest::SetSessionMode { session, .. }
14947 | NodeRequest::SetSessionConfigOption { session, .. }
14948 | NodeRequest::SetSessionModel { session, .. }
14949 | NodeRequest::DiscoverHistory { session, .. }
14950 | NodeRequest::LoadHistory { session, .. }
14951 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14952 | NodeRequest::ExportContextPack { session } => shared
14953 .session_bindings
14954 .lock()
14955 .unwrap_or_else(|poisoned| poisoned.into_inner())
14956 .get(&session.session.instance_id)
14957 .filter(|binding| {
14958 binding.workspace_id == session.workspace_id
14959 && binding.generation == session.session.generation
14960 })
14961 .map_or(true, |binding| binding.bundle.is_some()),
14962 NodeRequest::RenameSessionRecord { record_id, .. }
14963 | NodeRequest::SetSessionTask { record_id, .. }
14964 | NodeRequest::ResumeSessionRecord { record_id, .. }
14965 | NodeRequest::ForgetSessionRecord { record_id } => shared
14966 .record(record_id)
14967 .map_or(true, |record| record.bundle.is_some()),
14968 NodeRequest::ArmHarnessMcpReservation { .. }
14969 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14970 | NodeRequest::ActivateHarnessMcpReservation { .. }
14971 | NodeRequest::AbortHarnessMcpReservation { .. }
14972 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14973 | NodeRequest::RejectHarnessMcpCall { .. }
14974 | NodeRequest::Snapshot
14975 | NodeRequest::Resync { .. }
14976 | NodeRequest::BeginDeliveryStage { .. }
14977 | NodeRequest::PutDeliveryBlobChunk { .. }
14978 | NodeRequest::CommitDeliveryStage { .. }
14979 | NodeRequest::AbortDeliveryStage { .. }
14980 | NodeRequest::BrowseHostDirectories { .. }
14981 | NodeRequest::InspectWorkspace { .. }
14982 | NodeRequest::ReadWorkspaceFile { .. }
14983 | NodeRequest::WriteWorkspaceFile { .. }
14984 | NodeRequest::CreateWorkspaceFile { .. }
14985 | NodeRequest::CreateWorkspaceDirectory { .. }
14986 | NodeRequest::ReadGitHistory { .. }
14987 | NodeRequest::ReadGitDiff { .. }
14988 | NodeRequest::AcquireController { .. }
14989 | NodeRequest::ReleaseController
14990 | NodeRequest::Spawn { .. }
14991 | NodeRequest::SpawnWithOptions { .. }
14992 | NodeRequest::SpawnSpec { .. }
14993 | NodeRequest::RegisterWorkspace { .. }
14994 | NodeRequest::CreateStandaloneWorkspace { .. }
14995 | NodeRequest::UnregisterWorkspace { .. }
14996 | NodeRequest::CreateWorktree { .. }
14997 | NodeRequest::RemoveWorktree { .. }
14998 | NodeRequest::SpawnManagedWorktree { .. }
14999 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15000 | NodeRequest::CleanupManagedWorktree { .. }
15001 | NodeRequest::IndexProviderSession { .. }
15002 | NodeRequest::IndexNativeSession { .. }
15003 | NodeRequest::CatalogNativeSessions { .. }
15004 | NodeRequest::PageNativeSessions { .. }
15005 | NodeRequest::PreviewNativeSession { .. }
15006 | NodeRequest::PreviewSessionRecord { .. }
15007 | NodeRequest::ForgetContextPack { .. }
15008 | NodeRequest::ResolveDurableContextPack { .. }
15009 | NodeRequest::ReadContextPack { .. }
15010 | NodeRequest::Shutdown => false,
15011 }
15012}
15013
15014fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15015 if request.requires_history_context_pack_capability() {
15016 return true;
15017 }
15018 if let Some(spec) = match request {
15019 NodeRequest::SpawnSpec { spec }
15020 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15021 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15022 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15023 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15024 _ => None,
15025 } {
15026 return shared
15027 .resolve_spawn_spec(spec)
15028 .map_or(true, |resolved| resolved.context_id.is_some());
15029 }
15030 match request {
15031 NodeRequest::Resume { session, .. }
15032 | NodeRequest::Prompt { session, .. }
15033 | NodeRequest::Paste { session, .. }
15034 | NodeRequest::Input { session, .. }
15035 | NodeRequest::TerminalBytes { session, .. }
15036 | NodeRequest::TerminalControl { session, .. }
15037 | NodeRequest::Resize { session, .. }
15038 | NodeRequest::Interrupt { session }
15039 | NodeRequest::Stop { session, .. }
15040 | NodeRequest::Remove { session }
15041 | NodeRequest::ResolveInteraction { session, .. }
15042 | NodeRequest::SetSessionMode { session, .. }
15043 | NodeRequest::SetSessionConfigOption { session, .. }
15044 | NodeRequest::SetSessionModel { session, .. } => shared
15045 .session_bindings
15046 .lock()
15047 .unwrap_or_else(|poisoned| poisoned.into_inner())
15048 .get(&session.session.instance_id)
15049 .filter(|binding| {
15050 binding.workspace_id == session.workspace_id
15051 && binding.generation == session.session.generation
15052 })
15053 .map_or(true, |binding| binding.context.is_some()),
15054 NodeRequest::RenameSessionRecord { record_id, .. }
15055 | NodeRequest::SetSessionTask { record_id, .. }
15056 | NodeRequest::ResumeSessionRecord { record_id, .. }
15057 | NodeRequest::ForgetSessionRecord { record_id } => shared
15058 .record(record_id)
15059 .map_or(true, |record| record.context.is_some()),
15060 NodeRequest::ArmHarnessMcpReservation { .. }
15061 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15062 | NodeRequest::ActivateHarnessMcpReservation { .. }
15063 | NodeRequest::AbortHarnessMcpReservation { .. }
15064 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15065 | NodeRequest::RejectHarnessMcpCall { .. }
15066 | NodeRequest::Snapshot
15067 | NodeRequest::Resync { .. }
15068 | NodeRequest::BeginDeliveryStage { .. }
15069 | NodeRequest::PutDeliveryBlobChunk { .. }
15070 | NodeRequest::CommitDeliveryStage { .. }
15071 | NodeRequest::AbortDeliveryStage { .. }
15072 | NodeRequest::BrowseHostDirectories { .. }
15073 | NodeRequest::InspectWorkspace { .. }
15074 | NodeRequest::ReadWorkspaceFile { .. }
15075 | NodeRequest::WriteWorkspaceFile { .. }
15076 | NodeRequest::CreateWorkspaceFile { .. }
15077 | NodeRequest::CreateWorkspaceDirectory { .. }
15078 | NodeRequest::ReadGitHistory { .. }
15079 | NodeRequest::ReadGitDiff { .. }
15080 | NodeRequest::AcquireController { .. }
15081 | NodeRequest::ReleaseController
15082 | NodeRequest::Spawn { .. }
15083 | NodeRequest::SpawnWithOptions { .. }
15084 | NodeRequest::SpawnSpec { .. }
15085 | NodeRequest::RegisterWorkspace { .. }
15086 | NodeRequest::CreateStandaloneWorkspace { .. }
15087 | NodeRequest::UnregisterWorkspace { .. }
15088 | NodeRequest::CreateWorktree { .. }
15089 | NodeRequest::RemoveWorktree { .. }
15090 | NodeRequest::SpawnManagedWorktree { .. }
15091 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15092 | NodeRequest::CleanupManagedWorktree { .. }
15093 | NodeRequest::IndexProviderSession { .. }
15094 | NodeRequest::IndexNativeSession { .. }
15095 | NodeRequest::CatalogNativeSessions { .. }
15096 | NodeRequest::PageNativeSessions { .. }
15097 | NodeRequest::PreviewNativeSession { .. }
15098 | NodeRequest::PreviewSessionRecord { .. }
15099 | NodeRequest::DiscoverHistory { .. }
15100 | NodeRequest::LoadHistory { .. }
15101 | NodeRequest::ExportContextPackForSessionRecord { .. }
15102 | NodeRequest::ExportContextPack { .. }
15103 | NodeRequest::ForgetContextPack { .. }
15104 | NodeRequest::ResolveDurableContextPack { .. }
15105 | NodeRequest::ReadContextPack { .. }
15106 | NodeRequest::Shutdown => false,
15107 }
15108}
15109
15110fn request_requires_open_provider_ids_with(
15111 request: &NodeRequest,
15112 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15113 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15114) -> bool {
15115 match request {
15116 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15117 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15118 NodeRequest::CatalogNativeSessions { route, .. }
15119 | NodeRequest::PageNativeSessions { route, .. } => {
15120 !provider_id_is_legacy(&route.provider)
15121 }
15122 NodeRequest::PreviewNativeSession { selection, .. }
15123 | NodeRequest::IndexNativeSession { selection, .. } => {
15124 !provider_id_is_legacy(&selection.route.provider)
15125 }
15126 NodeRequest::SpawnSpec { .. }
15127 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15128 NodeRequest::SpawnManagedWorktree { .. }
15129 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15130 NodeRequest::Resume { session, .. }
15131 | NodeRequest::Prompt { session, .. }
15132 | NodeRequest::Paste { session, .. }
15133 | NodeRequest::Input { session, .. }
15134 | NodeRequest::TerminalBytes { session, .. }
15135 | NodeRequest::TerminalControl { session, .. }
15136 | NodeRequest::Resize { session, .. }
15137 | NodeRequest::Interrupt { session }
15138 | NodeRequest::Stop { session, .. }
15139 | NodeRequest::Remove { session }
15140 | NodeRequest::ResolveInteraction { session, .. }
15141 | NodeRequest::SetSessionMode { session, .. }
15142 | NodeRequest::SetSessionConfigOption { session, .. }
15143 | NodeRequest::SetSessionModel { session, .. }
15144 | NodeRequest::DiscoverHistory { session, .. }
15145 | NodeRequest::LoadHistory { session, .. }
15146 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15147 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15148 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15149 NodeRequest::RenameSessionRecord { record_id, .. }
15150 | NodeRequest::SetSessionTask { record_id, .. }
15151 | NodeRequest::ResumeSessionRecord { record_id, .. }
15152 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15153 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15154 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15155 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15156 NodeRequest::ArmHarnessMcpReservation { .. }
15157 | NodeRequest::ActivateHarnessMcpReservation { .. }
15158 | NodeRequest::AbortHarnessMcpReservation { .. }
15159 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15160 | NodeRequest::RejectHarnessMcpCall { .. }
15161 | NodeRequest::Snapshot
15162 | NodeRequest::Resync { .. }
15163 | NodeRequest::BeginDeliveryStage { .. }
15164 | NodeRequest::PutDeliveryBlobChunk { .. }
15165 | NodeRequest::CommitDeliveryStage { .. }
15166 | NodeRequest::AbortDeliveryStage { .. }
15167 | NodeRequest::BrowseHostDirectories { .. }
15168 | NodeRequest::InspectWorkspace { .. }
15169 | NodeRequest::ReadWorkspaceFile { .. }
15170 | NodeRequest::WriteWorkspaceFile { .. }
15171 | NodeRequest::CreateWorkspaceFile { .. }
15172 | NodeRequest::CreateWorkspaceDirectory { .. }
15173 | NodeRequest::ReadGitHistory { .. }
15174 | NodeRequest::ReadGitDiff { .. }
15175 | NodeRequest::AcquireController { .. }
15176 | NodeRequest::ReleaseController
15177 | NodeRequest::RegisterWorkspace { .. }
15178 | NodeRequest::CreateStandaloneWorkspace { .. }
15179 | NodeRequest::UnregisterWorkspace { .. }
15180 | NodeRequest::CreateWorktree { .. }
15181 | NodeRequest::RemoveWorktree { .. }
15182 | NodeRequest::CleanupManagedWorktree { .. }
15183 | NodeRequest::Shutdown => false,
15184 NodeRequest::ForgetContextPack { .. } => true,
15185 NodeRequest::ResolveDurableContextPack { .. }
15191 | NodeRequest::ReadContextPack { .. } => true,
15192 }
15193}
15194
15195fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15196 snapshot.enabled_providers.retain(provider_id_is_legacy);
15197 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15198 snapshot
15199 .provider_runtime_statuses
15200 .as_slice()
15201 .iter()
15202 .filter(|status| provider_id_is_legacy(status.provider()))
15203 .cloned(),
15204 )
15205 .expect("a filtered provider runtime projection remains valid");
15206 snapshot
15207 .session_records
15208 .retain(|record| provider_id_is_legacy(&record.provider));
15209 for workspace in &mut snapshot.workspaces {
15210 workspace
15211 .sessions
15212 .retain(|session| provider_id_is_legacy(&session.agent_id));
15213 }
15214}
15215
15216fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15217 for record in &mut snapshot.session_records {
15218 record.environment_profile = None;
15219 }
15220 if let Some(profiles) = snapshot
15221 .launch_inventory
15222 .as_mut()
15223 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15224 {
15225 for profile in profiles {
15226 profile.environment_profile = None;
15227 }
15228 }
15229}
15230
15231fn project_event_without_child_environment_profile(
15232 mut envelope: NodeEventEnvelope,
15233) -> NodeEventEnvelope {
15234 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15235 record.environment_profile = None;
15236 }
15237 envelope
15238}
15239
15240fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15241 let contains_environment_profile = match reply.result.as_ref() {
15242 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15243 receipt.environment_profile.is_some()
15244 }
15245 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15246 receipt.spawn.environment_profile.is_some()
15247 }
15248 _ => false,
15249 };
15250 if contains_environment_profile {
15251 reply.result = Err(failure(
15252 NodeFailureCode::UnsupportedCapability,
15253 "child environment profiles were not negotiated",
15254 ));
15255 return;
15256 }
15257 let Ok(response) = reply.result.as_mut() else {
15258 return;
15259 };
15260 match response {
15261 NodeResponse::Snapshot { snapshot, .. } => {
15262 clear_snapshot_child_environment_profiles(snapshot);
15263 }
15264 NodeResponse::Resync { snapshot, events, .. } => {
15265 clear_snapshot_child_environment_profiles(snapshot);
15266 for event in events {
15267 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15268 record.environment_profile = None;
15269 }
15270 }
15271 }
15272 NodeResponse::SessionRecordUpdated { record }
15273 | NodeResponse::ProviderSessionIndexed { record }
15274 | NodeResponse::NativeSessionIndexed { record, .. }
15275 | NodeResponse::SessionRecordResumed { record, .. } => {
15276 record.environment_profile = None;
15277 }
15278 NodeResponse::Armed { .. }
15279 | NodeResponse::Spawned { .. }
15280 | NodeResponse::Activated { .. }
15281 | NodeResponse::Aborted { .. }
15282 | NodeResponse::ReplyChunkAccepted { .. }
15283 | NodeResponse::CallRejected { .. }
15284 | NodeResponse::DeliveryStageBegun { .. }
15285 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15286 | NodeResponse::DeliveryCommitted { .. }
15287 | NodeResponse::DeliveryStageAborted { .. }
15288 | NodeResponse::WorkspaceInspected { .. }
15289 | NodeResponse::HostDirectoriesBrowsed { .. }
15290 | NodeResponse::WorkspaceFileRead { .. }
15291 | NodeResponse::WorkspaceFileWritten { .. }
15292 | NodeResponse::WorkspaceFileCreated { .. }
15293 | NodeResponse::WorkspaceDirectoryCreated { .. }
15294 | NodeResponse::GitHistoryRead { .. }
15295 | NodeResponse::GitDiffRead { .. }
15296 | NodeResponse::Controller { .. }
15297 | NodeResponse::SpawnAccepted { .. }
15298 | NodeResponse::SpawnSpecAccepted { .. }
15299 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15300 | NodeResponse::ManagedWorktreeCleanup { .. }
15301 | NodeResponse::NativeSessionsCataloged { .. }
15302 | NodeResponse::NativeSessionsPaged { .. }
15303 | NodeResponse::NativeSessionPreviewed { .. }
15304 | NodeResponse::SessionRecordPreviewed { .. }
15305 | NodeResponse::HistoryDiscovered { .. }
15306 | NodeResponse::HistoryLoaded { .. }
15307 | NodeResponse::ContextPackForSessionRecordExported { .. }
15308 | NodeResponse::ContextPackExported { .. }
15309 | NodeResponse::ContextPackForgotten { .. }
15310 | NodeResponse::DurableContextPackResolved { .. }
15311 | NodeResponse::ContextPackBytesRead { .. }
15312 | NodeResponse::SessionRecordForgotten { .. }
15313 | NodeResponse::WorkspaceRegistered { .. }
15314 | NodeResponse::StandaloneWorkspaceCreated { .. }
15315 | NodeResponse::WorkspaceUnregistered { .. }
15316 | NodeResponse::WorktreeCreated { .. }
15317 | NodeResponse::WorktreeRemoved { .. }
15318 | NodeResponse::Accepted
15319 | NodeResponse::ShuttingDown => {}
15320 }
15321}
15322
15323fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15324 for record in &mut snapshot.session_records {
15325 record.bundle = None;
15326 }
15327}
15328
15329fn project_event_without_session_bundle(
15330 mut envelope: NodeEventEnvelope,
15331) -> NodeEventEnvelope {
15332 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15333 record.bundle = None;
15334 }
15335 envelope
15336}
15337
15338fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15339 let contains_bundle = match reply.result.as_ref() {
15340 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15341 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15342 receipt.spawn.bundle.is_some()
15343 }
15344 _ => false,
15345 };
15346 if contains_bundle {
15347 reply.result = Err(failure(
15348 NodeFailureCode::UnsupportedCapability,
15349 "session bundles were not negotiated",
15350 ));
15351 return;
15352 }
15353 let Ok(response) = reply.result.as_mut() else {
15354 return;
15355 };
15356 match response {
15357 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15358 NodeResponse::Resync { snapshot, events, .. } => {
15359 clear_snapshot_session_bundles(snapshot);
15360 for event in events {
15361 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15362 record.bundle = None;
15363 }
15364 }
15365 }
15366 NodeResponse::SessionRecordUpdated { record }
15367 | NodeResponse::ProviderSessionIndexed { record }
15368 | NodeResponse::NativeSessionIndexed { record, .. }
15369 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15370 NodeResponse::Armed { .. }
15371 | NodeResponse::Spawned { .. }
15372 | NodeResponse::Activated { .. }
15373 | NodeResponse::Aborted { .. }
15374 | NodeResponse::ReplyChunkAccepted { .. }
15375 | NodeResponse::CallRejected { .. }
15376 | NodeResponse::DeliveryStageBegun { .. }
15377 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15378 | NodeResponse::DeliveryCommitted { .. }
15379 | NodeResponse::DeliveryStageAborted { .. }
15380 | NodeResponse::WorkspaceInspected { .. }
15381 | NodeResponse::HostDirectoriesBrowsed { .. }
15382 | NodeResponse::WorkspaceFileRead { .. }
15383 | NodeResponse::WorkspaceFileWritten { .. }
15384 | NodeResponse::WorkspaceFileCreated { .. }
15385 | NodeResponse::WorkspaceDirectoryCreated { .. }
15386 | NodeResponse::GitHistoryRead { .. }
15387 | NodeResponse::GitDiffRead { .. }
15388 | NodeResponse::Controller { .. }
15389 | NodeResponse::SpawnAccepted { .. }
15390 | NodeResponse::SpawnSpecAccepted { .. }
15391 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15392 | NodeResponse::ManagedWorktreeCleanup { .. }
15393 | NodeResponse::NativeSessionsCataloged { .. }
15394 | NodeResponse::NativeSessionsPaged { .. }
15395 | NodeResponse::NativeSessionPreviewed { .. }
15396 | NodeResponse::SessionRecordPreviewed { .. }
15397 | NodeResponse::HistoryDiscovered { .. }
15398 | NodeResponse::HistoryLoaded { .. }
15399 | NodeResponse::ContextPackForSessionRecordExported { .. }
15400 | NodeResponse::ContextPackExported { .. }
15401 | NodeResponse::ContextPackForgotten { .. }
15402 | NodeResponse::DurableContextPackResolved { .. }
15403 | NodeResponse::ContextPackBytesRead { .. }
15404 | NodeResponse::SessionRecordForgotten { .. }
15405 | NodeResponse::WorkspaceRegistered { .. }
15406 | NodeResponse::StandaloneWorkspaceCreated { .. }
15407 | NodeResponse::WorkspaceUnregistered { .. }
15408 | NodeResponse::WorktreeCreated { .. }
15409 | NodeResponse::WorktreeRemoved { .. }
15410 | NodeResponse::Accepted
15411 | NodeResponse::ShuttingDown => {}
15412 }
15413}
15414
15415fn project_snapshot_history_for_wire(
15416 snapshot: &mut NodeSnapshot,
15417 include_history_metadata: bool,
15418) {
15419 for workspace in &mut snapshot.workspaces {
15420 for session in &mut workspace.sessions {
15421 if include_history_metadata {
15422 session.history.loaded = None;
15423 } else {
15424 session.history = Default::default();
15425 }
15426 }
15427 }
15428}
15429
15430fn project_event_history_for_wire(
15431 mut envelope: NodeEventEnvelope,
15432 include_history_metadata: bool,
15433) -> NodeEventEnvelope {
15434 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15435 for session in &mut workspace.sessions {
15436 if include_history_metadata {
15437 session.history.loaded = None;
15438 } else {
15439 session.history = Default::default();
15440 }
15441 }
15442 }
15443 envelope
15444}
15445
15446fn project_response_history_for_wire(
15447 reply: &mut ResponseEnvelope,
15448 include_history_metadata: bool,
15449) {
15450 let Ok(response) = reply.result.as_mut() else {
15451 return;
15452 };
15453 match response {
15454 NodeResponse::Snapshot { snapshot, .. } => {
15455 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15456 }
15457 NodeResponse::Resync {
15458 snapshot, events, ..
15459 } => {
15460 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15461 for event in events {
15462 *event = project_event_history_for_wire(
15463 event.clone(),
15464 include_history_metadata,
15465 );
15466 }
15467 }
15468 NodeResponse::WorkspaceRegistered { workspace }
15469 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15470 | NodeResponse::WorktreeCreated { workspace, .. } => {
15471 for session in &mut workspace.sessions {
15472 if include_history_metadata {
15473 session.history.loaded = None;
15474 } else {
15475 session.history = Default::default();
15476 }
15477 }
15478 }
15479 _ => {}
15480 }
15481}
15482
15483fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15484 for record in &mut snapshot.session_records {
15485 record.context_id = None;
15486 record.context = None;
15487 record.exported_context = None;
15488 }
15489}
15490
15491fn project_event_without_context_pack(
15492 mut envelope: NodeEventEnvelope,
15493) -> NodeEventEnvelope {
15494 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15495 record.context_id = None;
15496 record.context = None;
15497 record.exported_context = None;
15498 }
15499 envelope
15500}
15501
15502fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15503 let contains_context = match reply.result.as_ref() {
15504 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15505 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15506 receipt.spawn.context.is_some()
15507 }
15508 Ok(NodeResponse::HistoryDiscovered { .. })
15509 | Ok(NodeResponse::HistoryLoaded { .. })
15510 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15511 | Ok(NodeResponse::ContextPackExported { .. })
15512 | Ok(NodeResponse::ContextPackForgotten { .. })
15513 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15514 _ => false,
15515 };
15516 if contains_context {
15517 reply.result = Err(failure(
15518 NodeFailureCode::UnsupportedCapability,
15519 "history context packs were not negotiated",
15520 ));
15521 return;
15522 }
15523 let Ok(response) = reply.result.as_mut() else {
15524 return;
15525 };
15526 match response {
15527 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15528 NodeResponse::Resync {
15529 snapshot, events, ..
15530 } => {
15531 clear_snapshot_context_packs(snapshot);
15532 for event in events {
15533 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15534 record.context_id = None;
15535 record.context = None;
15536 record.exported_context = None;
15537 }
15538 }
15539 }
15540 NodeResponse::SessionRecordUpdated { record }
15541 | NodeResponse::ProviderSessionIndexed { record }
15542 | NodeResponse::NativeSessionIndexed { record, .. }
15543 | NodeResponse::SessionRecordResumed { record, .. } => {
15544 record.context_id = None;
15545 record.context = None;
15546 record.exported_context = None;
15547 }
15548 _ => {}
15549 }
15550}
15551
15552fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15553 let contains_open_record = match reply.result.as_ref() {
15554 Ok(NodeResponse::SessionRecordUpdated { record })
15555 | Ok(NodeResponse::ProviderSessionIndexed { record })
15556 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15557 !provider_id_is_legacy(&record.provider)
15558 }
15559 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15560 !provider_id_is_legacy(&selection.route.provider)
15561 || !provider_id_is_legacy(&record.provider)
15562 }
15563 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15564 !provider_id_is_legacy(&receipt.provider)
15565 }
15566 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15567 !provider_id_is_legacy(&receipt.spawn.provider)
15568 }
15569 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15570 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15571 !provider_id_is_legacy(&route.provider)
15572 }
15573 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15574 !provider_id_is_legacy(&selection.route.provider)
15575 }
15576 _ => false,
15577 };
15578 if contains_open_record {
15579 reply.result = Err(failure(
15580 NodeFailureCode::UnsupportedCapability,
15581 "open provider IDs were not negotiated",
15582 ));
15583 return;
15584 }
15585 let Ok(response) = reply.result.as_mut() else {
15586 return;
15587 };
15588 match response {
15589 NodeResponse::Snapshot { snapshot, .. } => {
15590 project_snapshot_legacy_provider_ids(snapshot);
15591 }
15592 NodeResponse::Resync { snapshot, events, .. } => {
15593 project_snapshot_legacy_provider_ids(snapshot);
15594 *events = std::mem::take(events)
15595 .into_iter()
15596 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15597 .collect();
15598 }
15599 NodeResponse::WorkspaceRegistered { workspace }
15600 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15601 | NodeResponse::WorktreeCreated { workspace, .. } => {
15602 workspace
15603 .sessions
15604 .retain(|session| provider_id_is_legacy(&session.agent_id));
15605 }
15606 NodeResponse::Armed { .. }
15607 | NodeResponse::Spawned { .. }
15608 | NodeResponse::Activated { .. }
15609 | NodeResponse::Aborted { .. }
15610 | NodeResponse::ReplyChunkAccepted { .. }
15611 | NodeResponse::CallRejected { .. }
15612 | NodeResponse::DeliveryStageBegun { .. }
15613 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15614 | NodeResponse::DeliveryCommitted { .. }
15615 | NodeResponse::DeliveryStageAborted { .. }
15616 | NodeResponse::WorkspaceInspected { .. }
15617 | NodeResponse::HostDirectoriesBrowsed { .. }
15618 | NodeResponse::WorkspaceFileRead { .. }
15619 | NodeResponse::WorkspaceFileWritten { .. }
15620 | NodeResponse::WorkspaceFileCreated { .. }
15621 | NodeResponse::WorkspaceDirectoryCreated { .. }
15622 | NodeResponse::GitHistoryRead { .. }
15623 | NodeResponse::GitDiffRead { .. }
15624 | NodeResponse::Controller { .. }
15625 | NodeResponse::SpawnAccepted { .. }
15626 | NodeResponse::SpawnSpecAccepted { .. }
15627 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15628 | NodeResponse::ManagedWorktreeCleanup { .. }
15629 | NodeResponse::NativeSessionsCataloged { .. }
15630 | NodeResponse::NativeSessionsPaged { .. }
15631 | NodeResponse::NativeSessionPreviewed { .. }
15632 | NodeResponse::SessionRecordPreviewed { .. }
15633 | NodeResponse::HistoryDiscovered { .. }
15634 | NodeResponse::HistoryLoaded { .. }
15635 | NodeResponse::ContextPackForSessionRecordExported { .. }
15636 | NodeResponse::ContextPackExported { .. }
15637 | NodeResponse::ContextPackForgotten { .. }
15638 | NodeResponse::DurableContextPackResolved { .. }
15639 | NodeResponse::ContextPackBytesRead { .. }
15640 | NodeResponse::SessionRecordUpdated { .. }
15641 | NodeResponse::ProviderSessionIndexed { .. }
15642 | NodeResponse::NativeSessionIndexed { .. }
15643 | NodeResponse::SessionRecordResumed { .. }
15644 | NodeResponse::SessionRecordForgotten { .. }
15645 | NodeResponse::WorkspaceUnregistered { .. }
15646 | NodeResponse::WorktreeRemoved { .. }
15647 | NodeResponse::Accepted
15648 | NodeResponse::ShuttingDown => {}
15649 }
15650}
15651
15652fn project_event_legacy_provider_ids(
15653 shared: &NodeShared,
15654 mut envelope: NodeEventEnvelope,
15655) -> Option<NodeEventEnvelope> {
15656 let include = match &mut envelope.event {
15657 NodeEvent::HarnessMcpReadCall { .. } => true,
15658 NodeEvent::Control { address, .. }
15659 | NodeEvent::AgentStream { address, .. }
15660 | NodeEvent::TerminalFrame { address, .. } => shared
15661 .validate_address(address)
15662 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15663 NodeEvent::WorkspaceAdded { workspace } => {
15664 workspace
15665 .sessions
15666 .retain(|session| provider_id_is_legacy(&session.agent_id));
15667 true
15668 }
15669 NodeEvent::SessionRecordUpserted { record } => {
15670 provider_id_is_legacy(&record.provider)
15671 }
15672 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15673 NodeEvent::SessionRecordRemoved { .. } => shared
15674 .history
15675 .lock()
15676 .unwrap_or_else(|poisoned| poisoned.into_inner())
15677 .removed_record_providers
15678 .get(&envelope.sequence)
15679 .is_some_and(provider_id_is_legacy),
15680 NodeEvent::ControllerChanged { .. }
15681 | NodeEvent::ManagedWorktreeUpserted { .. }
15682 | NodeEvent::ManagedWorktreeRemoved { .. }
15683 | NodeEvent::WorkspaceRemoved { .. }
15684 | NodeEvent::ResyncRequired { .. } => true,
15685 };
15686 include.then_some(envelope)
15687}
15688
15689fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15690 let Ok(response) = reply.result.as_mut() else {
15691 return;
15692 };
15693 match response {
15694 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15695 snapshot.provider_runtime_statuses.clear();
15696 }
15697 NodeResponse::Armed { .. }
15698 | NodeResponse::Spawned { .. }
15699 | NodeResponse::Activated { .. }
15700 | NodeResponse::Aborted { .. }
15701 | NodeResponse::ReplyChunkAccepted { .. }
15702 | NodeResponse::CallRejected { .. }
15703 | NodeResponse::DeliveryStageBegun { .. }
15704 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15705 | NodeResponse::DeliveryCommitted { .. }
15706 | NodeResponse::DeliveryStageAborted { .. }
15707 | NodeResponse::WorkspaceInspected { .. }
15708 | NodeResponse::HostDirectoriesBrowsed { .. }
15709 | NodeResponse::WorkspaceFileRead { .. }
15710 | NodeResponse::WorkspaceFileWritten { .. }
15711 | NodeResponse::WorkspaceFileCreated { .. }
15712 | NodeResponse::WorkspaceDirectoryCreated { .. }
15713 | NodeResponse::GitHistoryRead { .. }
15714 | NodeResponse::GitDiffRead { .. }
15715 | NodeResponse::Controller { .. }
15716 | NodeResponse::SpawnAccepted { .. }
15717 | NodeResponse::SpawnSpecAccepted { .. }
15718 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15719 | NodeResponse::ManagedWorktreeCleanup { .. }
15720 | NodeResponse::NativeSessionsCataloged { .. }
15721 | NodeResponse::NativeSessionsPaged { .. }
15722 | NodeResponse::NativeSessionPreviewed { .. }
15723 | NodeResponse::SessionRecordPreviewed { .. }
15724 | NodeResponse::HistoryDiscovered { .. }
15725 | NodeResponse::HistoryLoaded { .. }
15726 | NodeResponse::ContextPackForSessionRecordExported { .. }
15727 | NodeResponse::ContextPackExported { .. }
15728 | NodeResponse::ContextPackForgotten { .. }
15729 | NodeResponse::DurableContextPackResolved { .. }
15730 | NodeResponse::ContextPackBytesRead { .. }
15731 | NodeResponse::SessionRecordUpdated { .. }
15732 | NodeResponse::ProviderSessionIndexed { .. }
15733 | NodeResponse::NativeSessionIndexed { .. }
15734 | NodeResponse::SessionRecordResumed { .. }
15735 | NodeResponse::SessionRecordForgotten { .. }
15736 | NodeResponse::WorkspaceRegistered { .. }
15737 | NodeResponse::StandaloneWorkspaceCreated { .. }
15738 | NodeResponse::WorkspaceUnregistered { .. }
15739 | NodeResponse::WorktreeCreated { .. }
15740 | NodeResponse::WorktreeRemoved { .. }
15741 | NodeResponse::Accepted
15742 | NodeResponse::ShuttingDown => {}
15743 }
15744}
15745
15746struct ControllerReleaseGuard {
15747 shared: Arc<NodeShared>,
15748 connection_id: u64,
15749}
15750
15751impl Drop for ControllerReleaseGuard {
15752 fn drop(&mut self) {
15753 self.shared.release_controller(self.connection_id);
15754 }
15755}
15756
15757struct AbortTaskOnDrop(AbortHandle);
15758
15759impl Drop for AbortTaskOnDrop {
15760 fn drop(&mut self) {
15761 self.0.abort();
15762 }
15763}
15764
15765fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15770 std::env::var(name)
15771 .ok()
15772 .and_then(|value| value.parse::<u64>().ok())
15773 .filter(|value| *value > 0)
15774 .unwrap_or(default)
15775 .min(max)
15776}
15777
15778fn workspace_inspection_time_budget_ms() -> u64 {
15779 env_config_bounded(
15780 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15781 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15782 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15783 )
15784}
15785
15786fn workspace_inspection_entry_cap() -> usize {
15787 env_config_bounded(
15788 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15789 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15790 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15791 ) as usize
15792}
15793
15794struct WorkspaceWalkBudget {
15800 deadline: Instant,
15801 entry_cap: usize,
15802 entries_visited: u64,
15803 time_budget_exceeded: bool,
15804 entry_cap_exceeded: bool,
15805}
15806
15807impl WorkspaceWalkBudget {
15808 fn new(deadline: Instant, entry_cap: usize) -> Self {
15809 Self {
15810 deadline,
15811 entry_cap,
15812 entries_visited: 0,
15813 time_budget_exceeded: false,
15814 entry_cap_exceeded: false,
15815 }
15816 }
15817
15818 fn is_exhausted(&self) -> bool {
15819 self.time_budget_exceeded || self.entry_cap_exceeded
15820 }
15821
15822 fn record_visit(&mut self) -> bool {
15825 self.entries_visited += 1;
15826 if self.entries_visited as usize >= self.entry_cap {
15827 self.entry_cap_exceeded = true;
15828 }
15829 if Instant::now() >= self.deadline {
15830 self.time_budget_exceeded = true;
15831 }
15832 self.is_exhausted()
15833 }
15834}
15835
15836fn collect_workspace_entries(
15837 root: &Path,
15838 deadline: Instant,
15839 entry_cap: usize,
15840) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15841 let mut entries = Vec::new();
15842 let mut truncated = false;
15843 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15844 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15845 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15849 (entries, truncated, budget)
15850}
15851
15852fn workspace_inspection_truncation(
15856 walk_budget: &WorkspaceWalkBudget,
15857 git_time_budget_exceeded: bool,
15858 elapsed: Duration,
15859) -> Option<WorkspaceInspectionTruncationV1> {
15860 if !walk_budget.time_budget_exceeded
15861 && !walk_budget.entry_cap_exceeded
15862 && !git_time_budget_exceeded
15863 {
15864 return None;
15865 }
15866 Some(WorkspaceInspectionTruncationV1 {
15867 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15868 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15869 git_time_budget_exceeded,
15870 entries_visited: walk_budget.entries_visited,
15871 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15872 })
15873}
15874
15875fn walk_workspace_directory(
15876 directory: &Path,
15877 relative_directory: Option<&RepositoryPath>,
15878 depth: usize,
15879 entries: &mut Vec<WorkspaceEntry>,
15880 truncated: &mut bool,
15881 budget: &mut WorkspaceWalkBudget,
15882) {
15883 if budget.is_exhausted() {
15884 *truncated = true;
15885 return;
15886 }
15887 let read_dir = match std::fs::read_dir(directory) {
15888 Ok(read_dir) => read_dir,
15889 Err(_) => {
15890 *truncated = true;
15891 return;
15892 }
15893 };
15894 let mut children = Vec::new();
15895 for child in read_dir {
15896 match child {
15897 Ok(child) => children.push(child),
15898 Err(_) => *truncated = true,
15899 }
15900 }
15901 children.sort_by_key(|child| child.file_name());
15902 for child in children {
15903 if budget.record_visit() {
15904 *truncated = true;
15905 return;
15906 }
15907 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15908 *truncated = true;
15909 return;
15910 }
15911 let file_type = match child.file_type() {
15912 Ok(file_type) => file_type,
15913 Err(_) => {
15914 *truncated = true;
15915 continue;
15916 }
15917 };
15918 if file_type.is_symlink() {
15919 continue;
15920 }
15921 let name = match child.file_name().into_string() {
15922 Ok(name) => name,
15923 Err(_) => {
15924 *truncated = true;
15925 continue;
15926 }
15927 };
15928 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15929 continue;
15930 }
15931 let relative_path = match relative_directory {
15932 None => windows_repository_path(name),
15933 Some(parent) => parent
15934 .as_utf8()
15935 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15936 };
15937 let Some(relative_path) = relative_path else {
15938 *truncated = true;
15939 continue;
15940 };
15941 let kind = if file_type.is_dir() {
15942 WorkspaceEntryKind::Directory
15943 } else if file_type.is_file() {
15944 WorkspaceEntryKind::File
15945 } else {
15946 continue;
15947 };
15948 entries.push(WorkspaceEntry {
15949 relative_path: relative_path.clone(),
15950 kind,
15951 });
15952 if file_type.is_dir() {
15953 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15954 walk_workspace_directory(
15955 &child.path(),
15956 Some(&relative_path),
15957 depth + 1,
15958 entries,
15959 truncated,
15960 budget,
15961 );
15962 } else {
15963 *truncated = true;
15964 }
15965 }
15966 }
15967}
15968
15969fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15970 if value.contains('\\') {
15971 return None;
15972 }
15973 RepositoryPath::utf8(value).ok()
15974}
15975
15976fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15977 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15978}
15979
15980fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15989 if name.eq_ignore_ascii_case(".git")
15990 || name.eq_ignore_ascii_case("target")
15991 || name.eq_ignore_ascii_case("node_modules")
15992 {
15993 return true;
15994 }
15995 path.join("CACHEDIR.TAG").is_file()
15996}
15997
15998fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15999 matches!(
16000 status,
16001 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16002 )
16003}
16004
16005#[derive(Clone, Debug, Eq, PartialEq)]
16006enum ContextPackRepositoryHead {
16007 NotRepository,
16008 Unborn,
16009 Commit(String),
16010}
16011
16012#[derive(Clone, Debug, Eq, PartialEq)]
16013struct ContextPackRepositoryObservation {
16014 head: ContextPackRepositoryHead,
16015 git: GitSnapshot,
16016 files: Vec<ContextPackRepositoryFileSource>,
16017}
16018
16019fn stable_context_pack_repository(
16020 first: ContextPackRepositoryObservation,
16021 second: ContextPackRepositoryObservation,
16022) -> Result<ContextPackRepository, NodeFailure> {
16023 if first != second {
16024 return Err(failure(
16025 NodeFailureCode::ContextPackMaterializationFailed,
16026 "context repository changed during bounded capture",
16027 ));
16028 }
16029 Ok(ContextPackRepository::from_sources(
16030 &second.git,
16031 second.files,
16032 ))
16033}
16034
16035fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16042 if Instant::now() < deadline {
16043 return false;
16044 }
16045 append_git_diagnostic(
16046 snapshot,
16047 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16048 );
16049 true
16050}
16051
16052async fn run_git_bounded_with_deadline(
16061 root: &str,
16062 arguments: &[&str],
16063 output_limit: usize,
16064 deadline: Instant,
16065) -> io::Result<GitCommandOutput> {
16066 let remaining = deadline.checked_duration_since(Instant::now());
16067 let timeout_ms = remaining
16068 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16069 .unwrap_or(0)
16070 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16071 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16072}
16073
16074async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16081 let mut snapshot = GitSnapshot {
16082 is_repository: false,
16083 branch: None,
16084 status: Vec::new(),
16085 recent_commits: Vec::new(),
16086 worktrees: Vec::new(),
16087 managed_worktree: None,
16088 truncated: false,
16089 diagnostic: None,
16090 };
16091 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16092 return (snapshot, true);
16093 }
16094 let repository = match run_git_bounded_with_deadline(
16095 root,
16096 &["rev-parse", "--is-inside-work-tree"],
16097 4 * 1_024,
16098 deadline,
16099 )
16100 .await
16101 {
16102 Ok(output) => output,
16103 Err(error) => {
16104 append_git_diagnostic(
16105 &mut snapshot,
16106 &format!("git inspection unavailable: {error}"),
16107 );
16108 return (snapshot, false);
16109 }
16110 };
16111 snapshot.truncated |= repository.truncated;
16112 if repository.timed_out {
16113 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16114 return (snapshot, false);
16115 }
16116 if !repository.success
16117 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16118 {
16119 let stderr = String::from_utf8_lossy(&repository.stderr);
16120 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16121 append_git_diagnostic(&mut snapshot, stderr.trim());
16122 }
16123 return (snapshot, false);
16124 }
16125 snapshot.is_repository = true;
16126
16127 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16128 return (snapshot, true);
16129 }
16130 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16131 Ok(output) => {
16132 snapshot.truncated |= output.truncated;
16133 if output.timed_out {
16134 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16135 } else if output.success {
16136 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16137 if !branch.is_empty() {
16138 snapshot.branch = Some(truncate_git_field(
16139 &branch,
16140 MAX_REPOSITORY_PATH_BYTES,
16141 &mut snapshot.truncated,
16142 ));
16143 }
16144 }
16145 }
16146 Err(error) => append_git_diagnostic(
16147 &mut snapshot,
16148 &format!("git branch query failed: {error}"),
16149 ),
16150 }
16151 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16152 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16153 snapshot.truncated |= output.truncated;
16154 if output.success && !output.timed_out {
16155 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16156 if !head.is_empty() {
16157 snapshot.branch = Some(format!("detached:{head}"));
16158 }
16159 }
16160 }
16161 }
16162
16163 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16164 return (snapshot, true);
16165 }
16166 match run_git_bounded_with_deadline(
16167 root,
16168 &[
16169 "status",
16170 "--porcelain=v1",
16171 "-z",
16172 "--renames",
16173 "--untracked-files=normal",
16174 "--",
16175 ".",
16176 ],
16177 GIT_OUTPUT_MAX_BYTES,
16178 deadline,
16179 )
16180 .await
16181 {
16182 Ok(output) => {
16183 snapshot.truncated |= output.truncated;
16184 if output.timed_out {
16185 append_git_diagnostic(&mut snapshot, "git status timed out");
16186 } else if output.success {
16187 parse_git_status(&output.stdout, &mut snapshot);
16188 } else {
16189 append_git_diagnostic(
16190 &mut snapshot,
16191 String::from_utf8_lossy(&output.stderr).trim(),
16192 );
16193 }
16194 }
16195 Err(error) => append_git_diagnostic(
16196 &mut snapshot,
16197 &format!("git status failed: {error}"),
16198 ),
16199 }
16200
16201 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16202 return (snapshot, true);
16203 }
16204 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16205 match run_git_bounded_with_deadline(
16206 root,
16207 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16208 16 * 1_024,
16209 deadline,
16210 )
16211 .await
16212 {
16213 Ok(output) => {
16214 snapshot.truncated |= output.truncated;
16215 if output.timed_out {
16216 append_git_diagnostic(&mut snapshot, "git log timed out");
16217 } else if output.success {
16218 parse_git_commits(&output.stdout, &mut snapshot);
16219 } else {
16220 let stderr = String::from_utf8_lossy(&output.stderr);
16221 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16222 append_git_diagnostic(&mut snapshot, stderr.trim());
16223 }
16224 }
16225 }
16226 Err(error) => append_git_diagnostic(
16227 &mut snapshot,
16228 &format!("git log failed: {error}"),
16229 ),
16230 }
16231
16232 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16233 return (snapshot, true);
16234 }
16235 match list_git_worktrees_with_deadline(root, deadline).await {
16236 Ok(worktrees) => {
16237 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16238 }
16239 Err(error) => append_git_diagnostic(
16240 &mut snapshot,
16241 &format!("git worktree list failed: {}", error.message),
16242 ),
16243 }
16244 (snapshot, false)
16245}
16246
16247fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16248 let mut remaining = output;
16249 while !remaining.is_empty() {
16250 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16251 snapshot.truncated = true;
16252 break;
16253 }
16254 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16255 snapshot.truncated = true;
16256 break;
16257 };
16258 let record = &remaining[..record_end];
16259 remaining = &remaining[record_end + 1..];
16260 let has_previous_path = record
16261 .get(..2)
16262 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16263 let previous_path_bytes = if has_previous_path {
16264 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16265 snapshot.truncated = true;
16266 break;
16267 };
16268 let previous = &remaining[..previous_end];
16269 remaining = &remaining[previous_end + 1..];
16270 Some(previous)
16271 } else {
16272 None
16273 };
16274 if record.len() < 4
16275 || record[2] != b' '
16276 || !is_git_status_code(record[0])
16277 || !is_git_status_code(record[1])
16278 {
16279 snapshot.truncated = true;
16280 continue;
16281 }
16282 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16283 snapshot.truncated = true;
16284 continue;
16285 };
16286 let previous_path = match previous_path_bytes {
16287 Some(previous) => {
16288 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16289 snapshot.truncated = true;
16290 continue;
16291 };
16292 Some(previous)
16293 }
16294 None => None,
16295 };
16296 snapshot.status.push(GitStatusEntry {
16297 index_status: (record[0] as char).to_string(),
16298 worktree_status: (record[1] as char).to_string(),
16299 path,
16300 previous_path,
16301 });
16302 }
16303}
16304
16305fn is_git_status_code(byte: u8) -> bool {
16306 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16307}
16308
16309fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16310 for line in String::from_utf8_lossy(output).lines() {
16311 let Some((id, summary)) = line.split_once('\u{1f}') else {
16312 continue;
16313 };
16314 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16315 snapshot.truncated = true;
16316 break;
16317 }
16318 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16319 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16320 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16321 }
16322}
16323
16324fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16325 if value.len() <= max_bytes {
16326 return value.to_owned();
16327 }
16328 *truncated = true;
16329 let mut boundary = max_bytes;
16330 while boundary > 0 && !value.is_char_boundary(boundary) {
16331 boundary -= 1;
16332 }
16333 value[..boundary].to_owned()
16334}
16335
16336fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16337 if message.is_empty() {
16338 return;
16339 }
16340 let normalized = message
16341 .chars()
16342 .map(|character| if character.is_control() { ' ' } else { character })
16343 .collect::<String>();
16344 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16345 if !diagnostic.is_empty() {
16346 diagnostic.push_str("; ");
16347 }
16348 diagnostic.push_str(normalized.trim());
16349 snapshot.diagnostic = Some(truncate_git_field(
16350 &diagnostic,
16351 GIT_DIAGNOSTIC_MAX_BYTES,
16352 &mut snapshot.truncated,
16353 ));
16354}
16355
16356async fn run_git_bounded(
16357 root: &str,
16358 arguments: &[&str],
16359 output_limit: usize,
16360) -> io::Result<GitCommandOutput> {
16361 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16362}
16363
16364async fn read_git_history_bounded(
16365 root: &str,
16366 path: Option<&RepositoryPath>,
16367 before: Option<&GitObjectId>,
16368 limit: u16,
16369) -> Result<GitHistoryPage, NodeFailure> {
16370 let path = path
16371 .map(|path| {
16372 path.as_utf8().ok_or_else(|| {
16373 failure(
16374 NodeFailureCode::InvalidRepositoryPath,
16375 "git history path must use UTF-8",
16376 )
16377 })
16378 })
16379 .transpose()?
16380 .unwrap_or(".");
16381 let count = (usize::from(limit) + 1).to_string();
16382 let start = before
16383 .map(|revision| format!("{}^", revision.as_str()))
16384 .unwrap_or_else(|| "HEAD".to_owned());
16385 let arguments = [
16386 "--no-pager",
16387 "log",
16388 "--no-decorate",
16389 "--date=iso-strict",
16390 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16391 "-n",
16392 count.as_str(),
16393 start.as_str(),
16394 "--",
16395 path,
16396 ];
16397 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16398 .await
16399 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16400 if output.timed_out {
16401 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16402 }
16403 if !output.success {
16404 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16405 }
16406 let mut commits = parse_git_history(&output.stdout);
16407 let has_more = commits.len() > usize::from(limit) || output.truncated;
16408 commits.truncate(usize::from(limit));
16409 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16410 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16411}
16412
16413fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16414 String::from_utf8_lossy(output)
16415 .split('\u{1e}')
16416 .filter_map(|record| {
16417 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16418 if fields.len() != 9 {
16419 return None;
16420 }
16421 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16422 let parents = if fields[1].is_empty() {
16423 Vec::new()
16424 } else {
16425 fields[1]
16426 .split(' ')
16427 .map(|value| GitObjectId::new(value.to_owned()))
16428 .collect::<Result<Vec<_>, _>>()
16429 .ok()?
16430 };
16431 Some(GitCommitDetails {
16432 id,
16433 parents,
16434 subject: bounded_git_text(fields[2], 512),
16435 author_name: bounded_git_text(fields[3], 256),
16436 author_email: bounded_git_text(fields[4], 320),
16437 authored_at: bounded_git_text(fields[5], 64),
16438 committer_name: bounded_git_text(fields[6], 256),
16439 committer_email: bounded_git_text(fields[7], 320),
16440 committed_at: bounded_git_text(fields[8], 64),
16441 signature_status: GitSignatureStatus::NoSignature,
16442 signer: None,
16443 })
16444 })
16445 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16446 .collect()
16447}
16448
16449fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16450 let normalized = value
16451 .chars()
16452 .map(|character| if character.is_control() { ' ' } else { character })
16453 .collect::<String>();
16454 if normalized.len() <= max_bytes {
16455 return normalized;
16456 }
16457 let mut boundary = max_bytes;
16458 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16459 boundary -= 1;
16460 }
16461 normalized[..boundary].to_owned()
16462}
16463
16464async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16465 let path = request.path.as_ref().map(|path| {
16466 path.as_utf8()
16467 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16468 }).transpose()?;
16469 let mut arguments = match &request.mode {
16470 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16471 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16472 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16473 };
16474 if let GitDiffMode::Commit { revision } = &request.mode {
16475 arguments.push(revision.as_str());
16476 }
16477 arguments.push("--");
16478 if let Some(path) = path {
16479 arguments.push(path);
16480 }
16481 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16482 .await
16483 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16484 if output.timed_out {
16485 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16486 }
16487 if !output.success {
16488 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16489 }
16490 Ok(GitDiff {
16491 mode: request.mode,
16492 path: request.path,
16493 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16494 truncated: output.truncated,
16495 })
16496}
16497
16498async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16499 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16500 ResponseEnvelope { request_id: envelope.request_id, result }
16501}
16502
16503async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16504 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16505 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16506 }
16507 let read_only = matches!(
16508 &request,
16509 NodeRequest::Snapshot
16510 | NodeRequest::Resync { .. }
16511 | NodeRequest::BrowseHostDirectories { .. }
16512 | NodeRequest::InspectWorkspace { .. }
16513 | NodeRequest::ReadWorkspaceFile { .. }
16514 | NodeRequest::ReadGitHistory { .. }
16515 | NodeRequest::ReadGitDiff { .. }
16516 | NodeRequest::CatalogNativeSessions { .. }
16517 | NodeRequest::PageNativeSessions { .. }
16518 | NodeRequest::PreviewNativeSession { .. }
16519 | NodeRequest::PreviewSessionRecord { .. }
16520 );
16521 let _mutation_guard = if read_only {
16522 None
16523 } else {
16524 let guard = shared.mutation_gate.lock().await;
16525 if shared.shutdown.load(Ordering::Acquire) {
16526 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16527 }
16528 Some(guard)
16529 };
16530 match request {
16531 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16532 event_sequence: shared.current_sequence(),
16533 controller: shared.controller_state(),
16534 snapshot: shared.snapshot(),
16535 }),
16536 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16537 NodeRequest::ArmHarnessMcpReservation {
16538 reservation_id,
16539 activation_digest,
16540 spawn_spec,
16541 launch,
16542 expires_at_unix_ms,
16543 } => {
16544 shared.require_controller(connection_id, role)?;
16545 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16546 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16547 })?;
16548 let expires_at_unix_ms = registry
16549 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16550 .await
16551 .map_err(harness_mcp_failure)?;
16552 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16553 }
16554 NodeRequest::SpawnSpecWithHarnessMcp {
16555 reservation_id,
16556 activation_digest,
16557 spec,
16558 deadline_unix_ms,
16559 } => {
16560 shared.require_controller(connection_id, role)?;
16561 let receipt = shared
16562 .spawn_from_spec_with_harness_mcp(
16563 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16564 )
16565 .await?;
16566 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16567 }
16568 NodeRequest::ActivateHarnessMcpReservation {
16569 reservation_id,
16570 activation_digest,
16571 record_id,
16572 session,
16573 } => {
16574 shared.require_controller(connection_id, role)?;
16575 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16576 current.instance_id == session.session.instance_id
16577 && current.generation == session.session.generation
16578 }).and_then(|current| current.process_id).ok_or_else(|| {
16579 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16580 })?;
16581 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16582 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16583 })?.activate(
16584 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16585 ).map_err(harness_mcp_failure)?;
16586 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16587 }
16588 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16589 shared.require_controller(connection_id, role)?;
16590 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16591 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16592 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16593 if let Some(instance_id) = instance_id {
16594 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16595 }
16596 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16597 }
16598 NodeRequest::PutHarnessMcpReplyChunk {
16599 reservation_id, activation_digest, record_id, session, call_id,
16600 offset, final_chunk, chunk_hex,
16601 } => {
16602 shared.require_controller(connection_id, role)?;
16603 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16604 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16605 })?.put_reply_chunk(
16606 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16607 offset, final_chunk, &chunk_hex,
16608 ).map_err(harness_mcp_failure)?;
16609 Ok(NodeResponse::ReplyChunkAccepted {
16610 reservation_id, activation_digest, record_id, session, call_id,
16611 next_offset, completed,
16612 })
16613 }
16614 NodeRequest::RejectHarnessMcpCall {
16615 reservation_id, activation_digest, record_id, session, call_id, reason,
16616 } => {
16617 shared.require_controller(connection_id, role)?;
16618 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16619 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16620 })?.reject_call(
16621 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16622 ).map_err(harness_mcp_failure)?;
16623 Ok(NodeResponse::CallRejected {
16624 reservation_id, activation_digest, record_id, session, call_id,
16625 })
16626 }
16627 NodeRequest::BeginDeliveryStage { manifest } => {
16628 shared.require_controller(connection_id, role)?;
16629 shared.begin_delivery_stage(manifest)
16630 }
16631 NodeRequest::PutDeliveryBlobChunk {
16632 stage_id,
16633 blob_digest,
16634 offset,
16635 chunk_hex,
16636 } => {
16637 shared.require_controller(connection_id, role)?;
16638 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16639 }
16640 NodeRequest::CommitDeliveryStage { stage_id } => {
16641 shared.require_controller(connection_id, role)?;
16642 let receipt = shared.commit_delivery_stage(stage_id)?;
16643 Ok(NodeResponse::DeliveryCommitted { receipt })
16644 }
16645 NodeRequest::AbortDeliveryStage { stage_id } => {
16646 shared.require_controller(connection_id, role)?;
16647 shared.abort_delivery_stage(&stage_id)?;
16648 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16649 }
16650 NodeRequest::BrowseHostDirectories { directory, after } => {
16651 let listing = shared.browse_host_directories(directory, after).await?;
16652 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16653 }
16654 NodeRequest::InspectWorkspace { workspace_id } => {
16655 let inspection = shared.inspect_workspace(workspace_id).await?;
16656 Ok(NodeResponse::WorkspaceInspected { inspection })
16657 }
16658 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16659 let file = shared.read_workspace_file(workspace_id, path).await?;
16660 Ok(NodeResponse::WorkspaceFileRead { file })
16661 }
16662 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16663 shared.require_controller(connection_id, role)?;
16664 let file = shared
16665 .write_workspace_file(workspace_id, path, expected_revision, text)
16666 .await?;
16667 Ok(NodeResponse::WorkspaceFileWritten { file })
16668 }
16669 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16670 shared.require_controller(connection_id, role)?;
16671 let file = shared.create_workspace_file(workspace_id, path).await?;
16672 Ok(NodeResponse::WorkspaceFileCreated { file })
16673 }
16674 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16675 shared.require_controller(connection_id, role)?;
16676 let entry = shared
16677 .create_workspace_directory(workspace_id.clone(), path)
16678 .await?;
16679 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16680 }
16681 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16682 if role != ClientRole::Operator {
16683 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16684 }
16685 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16686 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16687 }
16688 let page = shared
16689 .read_git_history(workspace_id.clone(), path, before, limit)
16690 .await?;
16691 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16692 }
16693 NodeRequest::ReadGitDiff { workspace_id, request } => {
16694 if role != ClientRole::Operator {
16695 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16696 }
16697 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16698 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16699 }
16700 NodeRequest::CatalogNativeSessions { route, limit } => {
16701 if role != ClientRole::Operator {
16702 return Err(failure(
16703 NodeFailureCode::ObserverReadOnly,
16704 "native session catalog requires an operator connection",
16705 ));
16706 }
16707 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16708 return Err(failure(
16709 NodeFailureCode::InvalidRequest,
16710 "native session catalog limit is invalid",
16711 ));
16712 }
16713 let (entries, summary) = shared
16714 .catalog_native_sessions(route.clone(), limit)
16715 .await?;
16716 Ok(NodeResponse::NativeSessionsCataloged {
16717 route,
16718 entries,
16719 summary: Some(summary),
16720 })
16721 }
16722 NodeRequest::PageNativeSessions {
16723 route,
16724 window,
16725 catalog_revision,
16726 recent_cutoff_unix_ms,
16727 after_selection_id,
16728 limit,
16729 } => {
16730 if role != ClientRole::Operator {
16731 return Err(failure(
16732 NodeFailureCode::ObserverReadOnly,
16733 "native session catalog paging requires an operator connection",
16734 ));
16735 }
16736 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16737 || after_selection_id
16738 .as_deref()
16739 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16740 {
16741 return Err(failure(
16742 NodeFailureCode::InvalidRequest,
16743 "native session catalog page request is invalid",
16744 ));
16745 }
16746 let page = shared
16747 .page_native_sessions(
16748 route.clone(),
16749 window,
16750 catalog_revision,
16751 recent_cutoff_unix_ms,
16752 after_selection_id,
16753 limit,
16754 )
16755 .await?;
16756 Ok(NodeResponse::NativeSessionsPaged {
16757 route,
16758 page,
16759 })
16760 }
16761 NodeRequest::PreviewNativeSession {
16762 selection,
16763 message_limit,
16764 } => {
16765 if role != ClientRole::Operator {
16766 return Err(failure(
16767 NodeFailureCode::ObserverReadOnly,
16768 "native session preview requires an operator connection",
16769 ));
16770 }
16771 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16772 .contains(&message_limit)
16773 || selection.validate().is_err()
16774 {
16775 return Err(failure(
16776 NodeFailureCode::InvalidRequest,
16777 "native session preview request is invalid",
16778 ));
16779 }
16780 let preview = shared
16781 .preview_native_session(selection.clone(), message_limit)
16782 .await?;
16783 Ok(NodeResponse::NativeSessionPreviewed {
16784 selection,
16785 preview,
16786 })
16787 }
16788 NodeRequest::IndexNativeSession {
16789 selection,
16790 display_name,
16791 } => {
16792 shared.require_controller(connection_id, role)?;
16793 let record = shared
16794 .index_native_session(selection.clone(), display_name)
16795 .await?;
16796 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16797 }
16798 NodeRequest::PreviewSessionRecord {
16799 record_id,
16800 message_limit,
16801 } => {
16802 if role != ClientRole::Operator {
16803 return Err(failure(
16804 NodeFailureCode::ObserverReadOnly,
16805 "native session preview requires an operator connection",
16806 ));
16807 }
16808 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16809 .contains(&message_limit)
16810 {
16811 return Err(failure(
16812 NodeFailureCode::InvalidRequest,
16813 "native session preview request is invalid",
16814 ));
16815 }
16816 let record = shared.record(&record_id)?;
16817 let working_directory = shared.workspace_root(&record.workspace_id)?;
16818 if !platform::roots_equal(
16819 &working_directory,
16820 windows_path_text(&record.canonical_root),
16821 ) {
16822 return Err(failure(
16823 NodeFailureCode::SessionRecordConflict,
16824 "managed session workspace root changed; refusing to preview another directory",
16825 ));
16826 }
16827 let identity = record.provider_session.clone().ok_or_else(|| {
16828 failure(
16829 NodeFailureCode::BackendOperationFailed,
16830 "native session preview is unavailable for this session record",
16831 )
16832 })?;
16833 if identity.key != ProviderSessionKey::SessionId {
16834 return Err(failure(
16835 NodeFailureCode::BackendOperationFailed,
16836 "native session preview is unavailable for this session identity",
16837 ));
16838 }
16839 let preview = shared
16840 .preview_native_session_by_session_id(
16841 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16842 )
16843 .await?;
16844 if preview.session_id != identity.id {
16845 return Err(failure(
16846 NodeFailureCode::SessionRecordConflict,
16847 "native session preview identity changed while loading",
16848 ));
16849 }
16850 shared.revalidate_session_record_preview(&record, &identity)?;
16851 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16852 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16853 record_id: record_id.clone(),
16854 summary: SessionHistorySummaryV1::from(&preview),
16855 });
16856 Ok(NodeResponse::SessionRecordPreviewed {
16857 record_id,
16858 preview,
16859 })
16860 }
16861 NodeRequest::AcquireController { lease_ms } => {
16862 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16863 Ok(NodeResponse::Controller { controller: Some(controller) })
16864 }
16865 NodeRequest::ReleaseController => {
16866 shared.release_controller(connection_id);
16867 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16868 }
16869 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16870 shared.require_controller(connection_id, role)?;
16871 let root = require_windows_path(root)?;
16872 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16873 let workspace = shared.register_workspace(workspace_id, root).await?;
16874 Ok(NodeResponse::WorkspaceRegistered { workspace })
16875 }
16876 NodeRequest::CreateStandaloneWorkspace {
16877 workspace_id,
16878 root,
16879 initial_branch,
16880 } => {
16881 shared.require_controller(connection_id, role)?;
16882 let root = require_windows_path(root)?;
16883 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16884 let workspace = shared
16885 .create_standalone_workspace(workspace_id, root, initial_branch)
16886 .await?;
16887 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16888 }
16889 NodeRequest::UnregisterWorkspace { workspace_id } => {
16890 shared.require_controller(connection_id, role)?;
16891 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16892 shared.unregister_workspace(&workspace_id)?;
16893 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16894 }
16895 NodeRequest::CreateWorktree {
16896 source_workspace_id,
16897 workspace_id,
16898 target_root,
16899 branch,
16900 base,
16901 } => {
16902 shared.require_controller(connection_id, role)?;
16903 let target_root = require_windows_path(target_root)?;
16904 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16905 let (worktree, workspace) = shared
16906 .create_worktree(
16907 source_workspace_id,
16908 workspace_id,
16909 target_root,
16910 branch,
16911 base,
16912 )
16913 .await?;
16914 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16915 }
16916 NodeRequest::RemoveWorktree {
16917 source_workspace_id,
16918 target_root,
16919 } => {
16920 shared.require_controller(connection_id, role)?;
16921 let native_target_root = require_windows_path(target_root.clone())?;
16922 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16923 let workspace_id = shared
16924 .remove_worktree(source_workspace_id, native_target_root)
16925 .await?;
16926 Ok(NodeResponse::WorktreeRemoved {
16927 target_root,
16928 workspace_id,
16929 })
16930 }
16931 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16932 shared.require_controller(connection_id, role)?;
16933 if !terminal_size.is_valid() {
16934 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16935 }
16936 if let Some(prompt) = initial_prompt.as_deref() {
16937 validate_node_text("spawn initial prompt", prompt)?;
16938 }
16939 match shared
16940 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16941 .await
16942 {
16943 Ok(session) => {
16944 shared.mark_spawn_provisional(session.clone());
16945 Ok(NodeResponse::SpawnAccepted { session })
16946 }
16947 Err(err) => {
16948 tracing::warn!(
16949 %provider,
16950 %workspace_id,
16951 ?mode,
16952 code = ?err.code,
16953 message = %err.message,
16954 "session spawn refused"
16955 );
16956 Err(err)
16957 }
16958 }
16959 }
16960 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16961 shared.require_controller(connection_id, role)?;
16962 if !terminal_size.is_valid() {
16963 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16964 }
16965 if let Some(prompt) = initial_prompt.as_deref() {
16966 validate_node_text("spawn initial prompt", prompt)?;
16967 }
16968 let plan = backend_paths::plan_launch_options(
16969 provider.as_str(),
16970 mode,
16971 options.model.is_some(),
16972 options.effort.is_some(),
16973 )
16974 .map_err(|r| {
16975 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16976 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16977 })?;
16978 let selection = spawn_selection(&options)?;
16979 let pty_selection = match plan {
16980 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16981 _ => None,
16982 };
16983 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16984 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16985 SPAWN_SESSION_OPTIONS
16986 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16987 .await
16988 }).await
16989 }).await;
16990 match spawned {
16991 Ok(session) => {
16992 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16993 shared
16994 .pending_acp_options
16995 .lock()
16996 .unwrap_or_else(|p| p.into_inner())
16997 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16998 }
16999 shared.mark_spawn_provisional(session.clone());
17000 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17001 Ok(NodeResponse::SpawnAccepted { session })
17002 }
17003 Err(err) => {
17004 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17005 Err(err)
17006 }
17007 }
17008 }
17009 NodeRequest::SpawnSpec { spec } => {
17010 shared.require_controller(connection_id, role)?;
17011 let receipt = shared.spawn_from_spec(spec).await?;
17012 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17013 }
17014 NodeRequest::SpawnManagedWorktree { request } => {
17015 shared.require_controller(connection_id, role)?;
17016 let receipt = shared.spawn_managed_worktree(request).await?;
17017 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17018 }
17019 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17020 shared.require_controller(connection_id, role)?;
17021 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17022 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17023 }
17024 NodeRequest::CleanupManagedWorktree { lease_id } => {
17025 shared.require_controller(connection_id, role)?;
17026 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17027 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17028 }
17029 NodeRequest::DiscoverHistory { session, limit } => {
17030 controlled_session(shared, connection_id, role, &session)?;
17031 let candidates = shared.discover_history(&session, limit).await?;
17032 Ok(NodeResponse::HistoryDiscovered {
17033 session,
17034 candidates,
17035 })
17036 }
17037 NodeRequest::LoadHistory {
17038 session,
17039 candidate_id,
17040 } => {
17041 controlled_session(shared, connection_id, role, &session)?;
17042 let loaded = shared
17043 .load_history(&session, candidate_id)
17044 .await?;
17045 Ok(NodeResponse::HistoryLoaded {
17046 session,
17047 session_id: loaded.session_id,
17048 message_count: loaded.message_count,
17049 completed_turn_count: loaded.completed_turn_count,
17050 })
17051 }
17052 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17053 shared.require_controller(connection_id, role)?;
17054 let context = shared
17055 .export_context_pack_for_session_record(&record_id, &session, false)
17056 .await?;
17057 Ok(NodeResponse::ContextPackForSessionRecordExported {
17058 record_id,
17059 session,
17060 context,
17061 })
17062 }
17063 NodeRequest::ExportContextPack { session } => {
17064 controlled_session(shared, connection_id, role, &session)?;
17065 let context = shared.export_context_pack(&session).await?;
17066 Ok(NodeResponse::ContextPackExported { context })
17067 }
17068 NodeRequest::ForgetContextPack { context_id } => {
17069 shared.require_controller(connection_id, role)?;
17070 shared.forget_context_pack(&context_id)?;
17071 Ok(NodeResponse::ContextPackForgotten { context_id })
17072 }
17073 NodeRequest::ResolveDurableContextPack { context_id } => {
17074 let context = shared
17078 .context_catalog
17079 .read()
17080 .unwrap_or_else(|poisoned| poisoned.into_inner())
17081 .get(&context_id)
17082 .map(|pack| pack.receipt().clone())
17083 .ok_or_else(|| {
17084 failure(
17085 NodeFailureCode::UnknownContextPack,
17086 "context pack is not present",
17087 )
17088 })?;
17089 Ok(NodeResponse::DurableContextPackResolved { context })
17090 }
17091 NodeRequest::ReadContextPack { digest } => {
17092 let (id, byte_len, bytes) = shared
17097 .context_catalog
17098 .read()
17099 .unwrap_or_else(|poisoned| poisoned.into_inner())
17100 .get_by_digest(&digest)
17101 .map(|pack| {
17102 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17103 })
17104 .ok_or_else(|| {
17105 failure(
17106 NodeFailureCode::UnknownContextPack,
17107 &format!("context pack {digest} not on this node"),
17108 )
17109 })?;
17110 tracing::debug!(
17111 digest = digest.as_str(),
17112 byte_len,
17113 "read context pack bytes",
17114 );
17115 Ok(NodeResponse::ContextPackBytesRead {
17116 pack: ContextPackBytesRead {
17117 digest,
17118 id,
17119 byte_len,
17120 bytes,
17121 },
17122 })
17123 }
17124 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17125 let provider = controlled_session(shared, connection_id, role, &session)?;
17126 if let Some(prompt) = initial_prompt.as_deref() {
17127 validate_node_text("resume initial prompt", prompt)?;
17128 }
17129 let runtime_requirement = if initial_prompt.is_some() {
17130 ProviderRuntimeRequirement::ResumeWithPrompt
17131 } else {
17132 ProviderRuntimeRequirement::Resume
17133 };
17134 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17135 let runtime_policy = shared
17136 .admit_provider_runtime(&provider, runtime_requirement)
17137 .await?;
17138 let working_directory = shared.bound_workspace_root(&session)?;
17139 let request = ResumeLaunchRequest {
17140 working_directory,
17141 terminal_size,
17142 initial_prompt,
17143 };
17144 request
17145 .validate()
17146 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17147 let command = shared.prepare_command(ControlCommand::Resume {
17148 instance_id: session.session.instance_id,
17149 target: ResumeTarget::CurrentProvider,
17150 runtime_policy,
17151 request,
17152 });
17153 shared.arm_resume(&session, command.id, runtime_policy)?;
17154 let dispatch = shared.dispatch_envelope(command);
17155 if let Err(error) = dispatch {
17156 shared.clear_armed_resume(&session);
17157 return Err(error);
17158 }
17159 Ok(NodeResponse::Accepted)
17160 }
17161 NodeRequest::RenameSessionRecord {
17162 record_id,
17163 display_name,
17164 } => {
17165 shared.require_controller(connection_id, role)?;
17166 let record = shared.rename_session_record(&record_id, display_name)?;
17167 Ok(NodeResponse::SessionRecordUpdated { record })
17168 }
17169 NodeRequest::SetSessionTask {
17170 record_id,
17171 expected_revision,
17172 target,
17173 } => {
17174 shared.require_controller(connection_id, role)?;
17175 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17176 Ok(NodeResponse::SessionRecordUpdated { record })
17177 }
17178 NodeRequest::IndexProviderSession {
17179 workspace_id,
17180 provider,
17181 identity,
17182 display_name,
17183 } => {
17184 shared.require_controller(connection_id, role)?;
17185 let record = shared.index_provider_session(
17186 workspace_id,
17187 provider,
17188 identity,
17189 display_name,
17190 )?;
17191 Ok(NodeResponse::ProviderSessionIndexed { record })
17192 }
17193 NodeRequest::ResumeSessionRecord {
17194 record_id,
17195 terminal_size,
17196 initial_prompt,
17197 } => {
17198 shared.require_controller(connection_id, role)?;
17199 if let Some(prompt) = initial_prompt.as_deref() {
17200 validate_node_text("managed resume initial prompt", prompt)?;
17201 }
17202 let (record, session) = shared
17203 .resume_session_record(
17204 &record_id,
17205 terminal_size,
17206 initial_prompt,
17207 )
17208 .await?;
17209 Ok(NodeResponse::SessionRecordResumed { record, session })
17210 }
17211 NodeRequest::ForgetSessionRecord { record_id } => {
17212 shared.require_controller(connection_id, role)?;
17213 shared.forget_session_record(&record_id).await?;
17214 Ok(NodeResponse::SessionRecordForgotten { record_id })
17215 }
17216 NodeRequest::Prompt { session, text } => {
17217 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17218 validate_node_text("prompt", &text)?;
17219 shared.require_session_runtime_policy(
17220 &session,
17221 ProviderRuntimeRequirement::SemanticPrompt,
17222 )?;
17223 let framing = prompt_framing(&agent_id);
17224 shared
17225 .dispatch_input_bounded(
17226 &session,
17227 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17228 )
17229 .await?;
17230 Ok(NodeResponse::Accepted)
17231 }
17232 NodeRequest::Paste { session, text } => {
17233 controlled_session(shared, connection_id, role, &session)?;
17234 validate_node_text("paste", &text)?;
17235 shared.require_session_runtime_policy(
17236 &session,
17237 ProviderRuntimeRequirement::SemanticPrompt,
17238 )?;
17239 shared
17240 .dispatch_input_bounded(
17241 &session,
17242 InputAction::InsertDraft(PromptPayload {
17243 text,
17244 framing: PromptFraming::BracketedPaste,
17245 }),
17246 )
17247 .await?;
17248 Ok(NodeResponse::Accepted)
17249 }
17250 NodeRequest::Input { session, text } => {
17251 controlled_session(shared, connection_id, role, &session)?;
17252 validate_node_text("terminal input", &text)?;
17253 shared
17254 .dispatch_input_bounded(
17255 &session,
17256 InputAction::TerminalText(TerminalText { text }),
17257 )
17258 .await?;
17259 Ok(NodeResponse::Accepted)
17260 }
17261 NodeRequest::TerminalBytes { session, bytes } => {
17262 controlled_session(shared, connection_id, role, &session)?;
17263 shared.sessionize_spawn(session.session.instance_id);
17264 let action = terminal_bytes_action(bytes)?;
17265 shared
17266 .dispatch_input_bounded(&session, action)
17267 .await?;
17268 Ok(NodeResponse::Accepted)
17269 }
17270 NodeRequest::TerminalControl { session, control } => {
17271 controlled_session(shared, connection_id, role, &session)?;
17272 shared
17273 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17274 .await?;
17275 Ok(NodeResponse::Accepted)
17276 }
17277 NodeRequest::Resize { session, size } => {
17278 controlled_session(shared, connection_id, role, &session)?;
17279 shared.sessionize_spawn(session.session.instance_id);
17280 if !size.is_valid() {
17281 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17282 }
17283 shared.dispatch_resize_bounded(&session, size).await?;
17284 Ok(NodeResponse::Accepted)
17285 }
17286 NodeRequest::Interrupt { session } => {
17287 controlled_session(shared, connection_id, role, &session)?;
17288 shared
17289 .dispatch_input_bounded(
17290 &session,
17291 InputAction::TerminalControl(TerminalControl::Interrupt),
17292 )
17293 .await?;
17294 Ok(NodeResponse::Accepted)
17295 }
17296 NodeRequest::Stop { session, force } => {
17297 controlled_session(shared, connection_id, role, &session)?;
17298 shared.sessionize_spawn(session.session.instance_id);
17299 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17300 shared.retire_session_grok_leader(session.session.instance_id);
17301 Ok(NodeResponse::Accepted)
17302 }
17303 NodeRequest::Remove { session } => {
17304 controlled_session(shared, connection_id, role, &session)?;
17305 shared.remove_session(&session).await?;
17306 Ok(NodeResponse::Accepted)
17307 }
17308 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17309 shared.require_controller(connection_id, role)?;
17310 let snapshot = shared.require_acp_session(&session)?;
17311 let interaction = snapshot
17312 .provider
17313 .interactions
17314 .iter()
17315 .find(|interaction| {
17316 interaction_correlation(
17317 session.session.instance_id,
17318 session.session.generation,
17319 interaction.id.0,
17320 ) == correlation_id
17321 })
17322 .ok_or_else(|| failure(
17323 NodeFailureCode::InvalidRequest,
17324 "unknown interaction correlation",
17325 ))?;
17326 if interaction.status != ProviderInteractionStatus::Pending {
17327 return Err(failure(
17328 NodeFailureCode::BackendOperationFailed,
17329 "interaction has already been resolved",
17330 ));
17331 }
17332 response
17333 .validate_for(interaction.interaction_kind)
17334 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17335 let interaction_id = interaction.id;
17336 let response_kind = response.kind();
17337 let response_outcome = response.outcome();
17338 shared.dispatch(ControlCommand::ResolveInteraction {
17339 instance_id: session.session.instance_id,
17340 generation: session.session.generation,
17341 interaction_id,
17342 response,
17343 })?;
17344 tracing::info!(
17350 node_id = %shared.node_id,
17351 session = ?session.session,
17352 correlation_id = %correlation_id,
17353 interaction_id = interaction_id.0,
17354 response_kind = ?response_kind,
17355 outcome = ?response_outcome,
17356 "interaction resolved",
17357 );
17358 Ok(NodeResponse::Accepted)
17359 }
17360 NodeRequest::SetSessionMode { session, mode_id } => {
17361 shared.require_controller(connection_id, role)?;
17362 shared.require_acp_session(&session)?;
17363 shared.dispatch(ControlCommand::SetSessionMode {
17364 instance_id: session.session.instance_id,
17365 mode_id,
17366 })?;
17367 Ok(NodeResponse::Accepted)
17368 }
17369 NodeRequest::SetSessionConfigOption {
17370 session,
17371 option_id,
17372 value_json,
17373 } => {
17374 shared.require_controller(connection_id, role)?;
17375 shared.require_acp_session(&session)?;
17376 shared.dispatch(ControlCommand::SetSessionConfigOption {
17377 instance_id: session.session.instance_id,
17378 option_id,
17379 value_json,
17380 })?;
17381 Ok(NodeResponse::Accepted)
17382 }
17383 NodeRequest::SetSessionModel { session, model_id } => {
17384 shared.require_controller(connection_id, role)?;
17385 shared.require_acp_session(&session)?;
17386 shared.dispatch(ControlCommand::SetSessionModel {
17387 instance_id: session.session.instance_id,
17388 model_id,
17389 })?;
17390 Ok(NodeResponse::Accepted)
17391 }
17392 NodeRequest::Shutdown => {
17393 shared.require_controller(connection_id, role)?;
17394 shared.begin_shutdown_locked().await?;
17395 Ok(NodeResponse::ShuttingDown)
17396 }
17397 }
17398}
17399
17400fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17401 shared.require_controller(connection_id, role)?;
17402 shared.validate_address(session)
17403}
17404
17405fn select_history_candidate(
17411 candidates: &[HistoryCandidateSummary],
17412 provider_session_id: Option<&str>,
17413) -> Result<String, NodeFailure> {
17414 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17415 Some(id) => candidate.session_id_hint == id,
17416 None => true,
17417 });
17418 let candidate = matches.next().ok_or_else(|| {
17419 failure(
17420 NodeFailureCode::SessionRecordConflict,
17421 if provider_session_id.is_some() {
17422 "no history candidate matches the managed provider session"
17423 } else {
17424 "no history candidate exists for the managed session"
17425 },
17426 )
17427 })?;
17428 if matches.next().is_some() {
17429 return Err(failure(
17430 NodeFailureCode::SessionRecordConflict,
17431 if provider_session_id.is_some() {
17432 "history identity is ambiguous for the managed provider session"
17433 } else {
17434 "history is ambiguous without a verified provider session identity"
17435 },
17436 ));
17437 }
17438 Ok(candidate.id.clone())
17439}
17440
17441fn resolved_spawn_profile_summary(
17442 profile: &SpawnProfileDefaults,
17443 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17444) -> Option<SpawnProfileSummary> {
17445 let environment_profile = match profile.environment_profile_id.as_ref() {
17446 None => None,
17447 Some(profile_id) => {
17448 let binding = environment_profiles.get(profile_id)?;
17449 if binding.provider != profile.provider
17450 || binding.native_profile_id(profile.mode).is_none()
17451 {
17452 return None;
17453 }
17454 Some(ResolvedEnvironmentProfileReceipt {
17455 profile_id: binding.id.clone(),
17456 profile_revision: binding.revision.clone(),
17457 network_allowlist: None,
17458 browser_profile_id: None,
17459 })
17460 }
17461 };
17462 Some(SpawnProfileSummary {
17463 id: profile.profile_id.clone(),
17464 revision: profile.revision.clone(),
17465 environment_profile,
17466 })
17467}
17468
17469fn session_record_export_target_matches(
17488 expected: &ManagedSessionRecord,
17489 current: &ManagedSessionRecord,
17490 session: &SessionAddress,
17491 allow_clean_detachment: bool,
17492) -> bool {
17493 if current == expected && current.active_session.as_ref() == Some(session) {
17494 return current.state == ManagedSessionState::Live;
17495 }
17496 allow_clean_detachment
17497 && current.state == ManagedSessionState::Dormant
17498 && current.active_session.is_none()
17499 && current.record_id == expected.record_id
17500 && current.display_name == expected.display_name
17501 && current.provider == expected.provider
17502 && current.mode == expected.mode
17503 && current.workspace_id == expected.workspace_id
17504 && current.canonical_root == expected.canonical_root
17505 && current.provider_session == expected.provider_session
17506 && current.environment_profile == expected.environment_profile
17507 && current.bundle == expected.bundle
17508 && current.context_id == expected.context_id
17509 && current.context == expected.context
17510 && current.exported_context == expected.exported_context
17511 && current.task_binding == expected.task_binding
17512 && current.created_at_unix_ms == expected.created_at_unix_ms
17513 && current.last_error == expected.last_error
17514}
17515
17516fn session_record_context_export_binding_is_exact(
17517 expected: &ManagedSessionRecord,
17518 current: &ManagedSessionRecord,
17519 identity: Option<&ProviderSessionIdentity>,
17520 session: &SessionAddress,
17521 binding: &SessionBinding,
17522 provider: &AgentId,
17523 current_root: &str,
17524 allow_clean_detachment: bool,
17525) -> bool {
17526 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17527 && current.provider_session.as_ref() == identity
17528 && current.workspace_id == session.workspace_id
17529 && provider == ¤t.provider
17530 && binding.workspace_id == session.workspace_id
17531 && binding.generation == session.session.generation
17532 && binding.record_id.as_ref() == Some(¤t.record_id)
17533 && platform::roots_equal(
17534 current_root,
17535 windows_path_text(¤t.canonical_root),
17536 )
17537}
17538
17539fn session_record_context_export_source_is_usable(
17540 record: &ManagedSessionRecord,
17541 status: &SessionStatus,
17542 allow_clean_detachment: bool,
17543) -> bool {
17544 (record.state == ManagedSessionState::Live
17545 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17546 && context_pack_source_status_is_usable(status)
17547}
17548
17549fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17550 PromptFraming::BracketedPaste
17551}
17552
17553fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17554 if text.len() > MAX_NODE_TEXT_BYTES {
17555 return Err(failure(
17556 NodeFailureCode::InvalidRequest,
17557 &format!(
17558 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17559 text.len(),
17560 ),
17561 ));
17562 }
17563 Ok(())
17564}
17565
17566fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17567 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17568 return Err(failure(
17569 NodeFailureCode::InvalidRequest,
17570 &format!(
17571 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17572 bytes.len(),
17573 ),
17574 ));
17575 }
17576 Ok(())
17577}
17578
17579fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17580 validate_terminal_bytes(&bytes)?;
17581 Ok(InputAction::TerminalBytes(bytes))
17582}
17583
17584fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17585 if root.is_empty()
17586 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17587 || root.chars().any(char::is_control)
17588 {
17589 return Err(failure(
17590 NodeFailureCode::InvalidWorkspaceRoot,
17591 &format!(
17592 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17593 ),
17594 ));
17595 }
17596 Ok(())
17597}
17598
17599fn canonical_standalone_workspace_candidate(
17600 workspace_id: &WorkspaceId,
17601 root: &str,
17602) -> Result<String, NodeServerError> {
17603 let path = Path::new(root);
17604 match std::fs::symlink_metadata(path) {
17605 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17606 .map(|workspace| workspace.canonical_root),
17607 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17608 if !path.is_absolute() {
17609 return Err(NodeServerError::InvalidWorkspaceRoot {
17610 workspace_id: workspace_id.clone(),
17611 path: root.to_owned(),
17612 message: "path is not absolute".to_owned(),
17613 });
17614 }
17615 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17616 workspace_id: workspace_id.clone(),
17617 path: root.to_owned(),
17618 message: "path has no standalone directory name".to_owned(),
17619 })?;
17620 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17621 workspace_id: workspace_id.clone(),
17622 path: root.to_owned(),
17623 message: "path has no parent directory".to_owned(),
17624 })?;
17625 let canonical = std::fs::canonicalize(parent)
17626 .map(|parent| parent.join(name))
17627 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17628 workspace_id: workspace_id.clone(),
17629 path: root.to_owned(),
17630 message: error.to_string(),
17631 })?;
17632 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17633 NodeServerError::InvalidWorkspaceRoot {
17634 workspace_id: workspace_id.clone(),
17635 path: path.to_string_lossy().into_owned(),
17636 message: "canonical path is not valid Unicode".to_owned(),
17637 }
17638 })?;
17639 let canonical_root = platform::normalize_canonical_root(canonical_root);
17640 validate_workspace_root(workspace_id, &canonical_root)?;
17641 Ok(canonical_root)
17642 }
17643 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17644 workspace_id: workspace_id.clone(),
17645 path: root.to_owned(),
17646 message: error.to_string(),
17647 }),
17648 }
17649}
17650
17651fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17652 let Some(revision) = revision else {
17653 return Ok(());
17654 };
17655 if revision.is_empty()
17656 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17657 || revision.chars().any(char::is_control)
17658 {
17659 return Err(failure(
17660 NodeFailureCode::InvalidRequest,
17661 &format!(
17662 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17663 ),
17664 ));
17665 }
17666 Ok(())
17667}
17668
17669fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17670 let code = match error.kind {
17671 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17672 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17673 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17674 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17675 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17676 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17677 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17678 };
17679 failure(code, &error.message)
17680}
17681
17682fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17683 let code = match error.kind {
17684 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17685 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17686 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17687 NodeFailureCode::BackendOperationFailed
17688 }
17689 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17690 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17691 }
17692 };
17693 failure(code, error.message)
17694}
17695
17696fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17697 let (code, message) = match error.kind {
17698 GitWorktreeErrorKind::Invalid => (
17699 NodeFailureCode::InvalidRequest,
17700 "managed Git input is invalid",
17701 ),
17702 GitWorktreeErrorKind::NotRepository => (
17703 NodeFailureCode::NotGitRepository,
17704 "managed source is not a Git repository",
17705 ),
17706 GitWorktreeErrorKind::Conflict => (
17707 NodeFailureCode::WorktreeConflict,
17708 "managed Git identity conflicts with existing repository state",
17709 ),
17710 GitWorktreeErrorKind::Protected => (
17711 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17712 "managed worktree ownership validation failed",
17713 ),
17714 GitWorktreeErrorKind::Dirty => (
17715 NodeFailureCode::WorktreeDirty,
17716 "managed worktree contains uncommitted changes",
17717 ),
17718 GitWorktreeErrorKind::Locked => (
17719 NodeFailureCode::WorktreeLocked,
17720 "managed worktree is locked",
17721 ),
17722 GitWorktreeErrorKind::Failed => (
17723 NodeFailureCode::BackendOperationFailed,
17724 "managed Git operation failed",
17725 ),
17726 };
17727 NodeFailure { code, message: message.to_owned() }
17728}
17729
17730fn managed_spawn_receipt(
17731 resolved: &ResolvedSpawnSpec,
17732 incarnation_id: NodeIncarnationId,
17733 session: SessionAddress,
17734 lease: ManagedWorktreeLeaseSnapshot,
17735 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17736 bundle: Option<ResolvedBundleReceipt>,
17737 context: Option<ResolvedContextPackReceipt>,
17738) -> ManagedWorktreeSpawnReceipt {
17739 let mut managed = resolved.clone();
17740 managed.target.worktree_id = Some(lease.workspace_id.clone());
17741 ManagedWorktreeSpawnReceipt {
17742 spawn: managed.receipt_with_materialization(
17743 incarnation_id,
17744 session,
17745 environment_profile,
17746 bundle,
17747 context,
17748 ),
17749 lease,
17750 }
17751}
17752
17753fn validate_selected_worktree(
17754 source_root: &str,
17755 selected_root: &str,
17756 worktrees: &[NativeGitWorktreeSnapshot],
17757) -> Result<(), NodeFailure> {
17758 if !worktrees
17759 .iter()
17760 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17761 {
17762 return Err(failure(
17763 NodeFailureCode::WorktreeProtected,
17764 "source workspace is not an authoritative non-bare Git worktree root",
17765 ));
17766 }
17767 if !worktrees.iter().any(|worktree| {
17768 worktree_paths_equal(&worktree.path, selected_root)
17769 && !worktree.is_main
17770 && !worktree.is_bare
17771 }) {
17772 return Err(failure(
17773 NodeFailureCode::WorktreeProtected,
17774 "selected workspace is not an eligible Git worktree in the source repository",
17775 ));
17776 }
17777 Ok(())
17778}
17779
17780fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17781 let code = match error.kind() {
17782 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17783 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17784 NodeFailureCode::RepositoryPathUnsafe
17785 }
17786 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17787 WorkspaceFileReadErrorKind::NotRegularFile => {
17788 NodeFailureCode::RepositoryFileNotRegular
17789 }
17790 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17791 NodeFailureCode::RepositoryFileReadFailed
17792 }
17793 WorkspaceFileReadErrorKind::RevisionConflict => {
17794 NodeFailureCode::RepositoryFileRevisionConflict
17795 }
17796 WorkspaceFileReadErrorKind::AlreadyExists
17797 | WorkspaceFileReadErrorKind::ParentNotFound
17798 | WorkspaceFileReadErrorKind::ParentNotDirectory
17799 | WorkspaceFileReadErrorKind::Canceled => {
17800 NodeFailureCode::RepositoryFileReadFailed
17801 }
17802 };
17803 failure(code, "repository file read failed")
17804}
17805
17806fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17807 let code = match error.kind() {
17808 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17809 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17810 NodeFailureCode::RepositoryPathUnsafe
17811 }
17812 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17813 WorkspaceFileReadErrorKind::NotRegularFile => {
17814 NodeFailureCode::RepositoryFileNotRegular
17815 }
17816 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17817 NodeFailureCode::RepositoryFileWriteFailed
17818 }
17819 WorkspaceFileReadErrorKind::RevisionConflict => {
17820 NodeFailureCode::RepositoryFileRevisionConflict
17821 }
17822 WorkspaceFileReadErrorKind::AlreadyExists
17823 | WorkspaceFileReadErrorKind::ParentNotFound
17824 | WorkspaceFileReadErrorKind::ParentNotDirectory
17825 | WorkspaceFileReadErrorKind::Canceled => {
17826 NodeFailureCode::RepositoryFileWriteFailed
17827 }
17828 };
17829 failure(code, "repository file write failed")
17830}
17831
17832async fn settle_workspace_entry_create<T>(
17833 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17834 commit_state: Arc<AtomicU8>,
17835 deadline: Duration,
17836 timeout_message: &'static str,
17837 task_failure_message: &'static str,
17838) -> Result<T, NodeFailure> {
17839 let joined = match timeout(deadline, &mut task).await {
17840 Ok(joined) => joined,
17841 Err(_) => {
17842 if commit_state
17843 .compare_exchange(
17844 WORKSPACE_ENTRY_CREATE_PENDING,
17845 WORKSPACE_ENTRY_CREATE_CANCELED,
17846 Ordering::AcqRel,
17847 Ordering::Acquire,
17848 )
17849 .is_ok()
17850 {
17851 return Err(failure(
17852 NodeFailureCode::RepositoryEntryCreateTimedOut,
17853 timeout_message,
17854 ));
17855 }
17856 debug_assert_eq!(
17857 commit_state.load(Ordering::Acquire),
17858 WORKSPACE_ENTRY_CREATE_COMMITTING,
17859 );
17860 task.await
17861 }
17862 };
17863 joined
17864 .map_err(|_| {
17865 failure(
17866 NodeFailureCode::RepositoryEntryCreateFailed,
17867 task_failure_message,
17868 )
17869 })?
17870 .map_err(workspace_entry_create_failure)
17871}
17872
17873fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17874 let code = match error.kind() {
17875 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17876 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17877 NodeFailureCode::RepositoryPathUnsafe
17878 }
17879 WorkspaceFileReadErrorKind::AlreadyExists => {
17880 NodeFailureCode::RepositoryEntryAlreadyExists
17881 }
17882 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17883 NodeFailureCode::RepositoryParentNotFound
17884 }
17885 WorkspaceFileReadErrorKind::ParentNotDirectory
17886 | WorkspaceFileReadErrorKind::NotRegularFile => {
17887 NodeFailureCode::RepositoryParentNotDirectory
17888 }
17889 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17890 NodeFailureCode::RepositoryEntryCreateFailed
17891 }
17892 WorkspaceFileReadErrorKind::RevisionConflict => {
17893 NodeFailureCode::RepositoryEntryCreateFailed
17894 }
17895 WorkspaceFileReadErrorKind::Canceled => {
17896 NodeFailureCode::RepositoryEntryCreateTimedOut
17897 }
17898 };
17899 failure(code, "repository entry create failed")
17900}
17901
17902fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17903 let value = digest(&SHA256, bytes)
17904 .as_ref()
17905 .iter()
17906 .map(|byte| format!("{byte:02x}"))
17907 .collect();
17908 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17909}
17910
17911fn managed_spawn_request_digest_v2(
17912 request: &ManagedWorktreeSpawnRequestV2,
17913) -> Result<String, NodeFailure> {
17914 let encoded = serde_json::to_vec(request).map_err(|_| {
17915 failure(
17916 NodeFailureCode::InvalidRequest,
17917 "managed worktree V2 request could not be canonicalized",
17918 )
17919 })?;
17920 let mut value = String::with_capacity(71);
17921 value.push_str("sha256:");
17922 for byte in digest(&SHA256, &encoded).as_ref() {
17923 use std::fmt::Write as _;
17924 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17925 }
17926 Ok(value)
17927}
17928
17929fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17930 let code = match error {
17931 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17932 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17933 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17934 NativeSessionPreviewError::UnsupportedProvider
17935 | NativeSessionPreviewError::PreviewUnavailable
17936 | NativeSessionPreviewError::SessionNotFound
17937 | NativeSessionPreviewError::AmbiguousSession => {
17938 NodeFailureCode::BackendOperationFailed
17939 }
17940 };
17941 failure(code, "native session preview failed")
17942}
17943
17944fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17945 let code = match error.kind() {
17946 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17947 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17948 };
17949 failure(code, "host directory browse failed")
17950}
17951
17952fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17953 let code = match error {
17954 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17955 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17956 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17957 NodeFailureCode::DeliveryStageConflict
17958 }
17959 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17960 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17961 NodeFailureCode::DeliveryChunkOutOfOrder
17962 }
17963 DeliveryStoreError::BlobDigestMismatch => {
17964 NodeFailureCode::DeliveryBlobDigestMismatch
17965 }
17966 DeliveryStoreError::BundleDigestMismatch => {
17967 NodeFailureCode::DeliveryBundleDigestMismatch
17968 }
17969 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17970 DeliveryStoreError::Unavailable
17971 | DeliveryStoreError::Corrupt
17972 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17973 };
17974 failure(code, "delivery operation failed")
17975}
17976
17977fn validate_workspace_root(
17978 workspace_id: &WorkspaceId,
17979 canonical_root: &str,
17980) -> Result<(), NodeServerError> {
17981 #[cfg(windows)]
17982 if canonical_root.starts_with(r"\\") {
17983 return Err(NodeServerError::InvalidWorkspaceRoot {
17984 workspace_id: workspace_id.clone(),
17985 path: canonical_root.to_owned(),
17986 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17987 });
17988 }
17989 if !platform::workspace_root_supported(canonical_root)
17990 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17991 {
17992 return Err(NodeServerError::InvalidWorkspaceRoot {
17993 workspace_id: workspace_id.clone(),
17994 path: canonical_root.to_owned(),
17995 message: "canonical path must be absolute, bounded, and free of control characters"
17996 .to_owned(),
17997 });
17998 }
17999 Ok(())
18000}
18001
18002fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18003 NodeFailure {
18004 code,
18005 message: node_failure_category(code).to_owned(),
18006 }
18007}
18008
18009fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18010 let code = match error {
18011 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18012 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18013 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18014 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18015 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18016 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18017 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18018 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18019 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18020 };
18021 failure(code, "harness MCP proxy operation failed")
18022}
18023
18024static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18028
18029fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18036 let trace = launch.trace.as_ref()?;
18037 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18038 if trace_dir.is_empty() {
18039 return None;
18040 }
18041 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18042 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18043 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18044}
18045
18046fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18047 let remaining = deadline.saturating_duration_since(Instant::now());
18048 if remaining.is_zero() {
18049 Err(failure(
18050 NodeFailureCode::SpawnDeadlineExceeded,
18051 "spawn deadline elapsed",
18052 ))
18053 } else {
18054 Ok(remaining)
18055 }
18056}
18057
18058fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18059 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18060 match deadline {
18061 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18062 None => Ok(bounded),
18063 }
18064}
18065
18066fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18067 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18068 failure(
18069 NodeFailureCode::SpawnDeadlineExceeded,
18070 "spawn dispatch exceeded the spawn deadline",
18071 )
18072 } else {
18073 error
18074 }
18075}
18076
18077fn spawn_runtime_capabilities(
18078 capabilities: &SpawnRequiredCapabilities,
18079) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18080 capabilities
18081 .iter()
18082 .map(|capability| match capability.as_str() {
18083 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18084 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18085 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18086 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18087 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18088 }
18089 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18090 _ => Err(failure(
18091 NodeFailureCode::UnsupportedSpawnCapability,
18092 "spawn required capability is unknown",
18093 )),
18094 })
18095 .collect()
18096}
18097
18098fn persistence_failure(_error: io::Error) -> NodeFailure {
18099 NodeFailure {
18100 code: NodeFailureCode::BackendOperationFailed,
18101 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18102 }
18103}
18104
18105fn node_failure_category(code: NodeFailureCode) -> &'static str {
18106 match code {
18107 NodeFailureCode::InvalidRequest => "invalid-request",
18108 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18109 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18110 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18111 NodeFailureCode::ReservationConflict => "reservation-conflict",
18112 NodeFailureCode::ReservationExpired => "reservation-expired",
18113 NodeFailureCode::BindingMismatch => "binding-mismatch",
18114 NodeFailureCode::NotActivated => "not-activated",
18115 NodeFailureCode::CallNotFound => "call-not-found",
18116 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18117 NodeFailureCode::ResponseTooLarge => "response-too-large",
18118 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18119 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18120 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18121 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18122 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18123 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18124 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18125 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18126 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18127 NodeFailureCode::Unauthorized => "unauthorized",
18128 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18129 NodeFailureCode::ControllerBusy => "controller-busy",
18130 NodeFailureCode::ControllerRequired => "controller-required",
18131 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18132 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18133 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18134 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18135 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18136 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18137 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18138 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18139 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18140 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18141 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18142 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18143 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18144 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18145 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18146 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18147 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18148 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18149 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18150 NodeFailureCode::GitReadFailed => "git-read-failed",
18151 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18152 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18153 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18154 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18155 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18156 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18157 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18158 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18159 NodeFailureCode::TurnInFlight => "turn-in-flight",
18160 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18161 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18162 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18163 "unsupported-network-allowlist-mapping"
18164 }
18165 NodeFailureCode::BrowserStationProbeUnavailable => {
18166 "browser-station-probe-unavailable"
18167 }
18168 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18169 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18170 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18171 "environment-profile-binding-mismatch"
18172 }
18173 NodeFailureCode::UnknownBundle => "unknown-bundle",
18174 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18175 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18176 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18177 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18178 NodeFailureCode::ContextPackMaterializationFailed => {
18179 "context-pack-materialization-failed"
18180 }
18181 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18182 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18183 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18184 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18185 NodeFailureCode::LastWorkspace => "last-workspace",
18186 NodeFailureCode::NotGitRepository => "not-git-repository",
18187 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18188 NodeFailureCode::WorktreeProtected => "worktree-protected",
18189 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18190 NodeFailureCode::WorktreeLocked => "worktree-locked",
18191 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18192 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18193 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18194 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18195 "managed-worktree-profile-revision-mismatch"
18196 }
18197 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18198 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18199 "standalone-workspace-recovery-required"
18200 }
18201 NodeFailureCode::UnknownSession => "unknown-session",
18202 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18203 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18204 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18205 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18206 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18207 NodeFailureCode::WorkspaceRegistrationRequired => {
18208 "workspace-registration-required"
18209 }
18210 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18211 NodeFailureCode::StaleGeneration => "stale-generation",
18212 NodeFailureCode::BackendBusy => "backend-busy",
18213 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18214 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18215 NodeFailureCode::ShuttingDown => "shutting-down",
18216 }
18217}
18218
18219fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18220 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18221}
18222
18223fn durable_state_load_error(error: io::Error) -> NodeServerError {
18224 let category = match session_registry::state_load_refusal(&error) {
18225 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18226 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18227 }
18228 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18229 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18230 }
18231 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18232 DURABLE_STATE_CONFLICT_ERROR
18233 }
18234 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18235 };
18236 durable_state_server_error(error, category)
18237}
18238
18239fn unix_time_ms() -> u64 {
18240 std::time::SystemTime::now()
18241 .duration_since(std::time::UNIX_EPOCH)
18242 .unwrap_or_default()
18243 .as_millis()
18244 .min(u64::MAX as u128) as u64
18245}
18246
18247fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18248 if record.provider_session.is_some() {
18249 ManagedSessionState::Dormant
18250 } else {
18251 ManagedSessionState::Unavailable
18252 }
18253}
18254
18255fn active_record_state(
18256 identity_admitted: bool,
18257 provider_session_is_known: bool,
18258) -> ManagedSessionState {
18259 if identity_admitted && !provider_session_is_known {
18260 ManagedSessionState::IdentityPending
18261 } else {
18262 ManagedSessionState::Live
18263 }
18264}
18265
18266fn managed_worktree_record_holders(
18267 records: &[ManagedSessionRecord],
18268) -> Vec<(SessionRecordId, WorkspaceId)> {
18269 records
18270 .iter()
18271 .filter(|record| record.provider_session.is_some())
18272 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18273 .collect()
18274}
18275
18276fn managed_spawn_record_ownership(
18277 binding: &SessionBinding,
18278 effective_runtime_policy: ProviderRuntimePolicy,
18279) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18280 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18281 if effective_runtime_policy.provider_session_identity {
18282 (binding.record_id.clone(), None)
18283 } else {
18284 (None, binding.record_id.clone())
18285 }
18286}
18287
18288#[derive(Debug, Error)]
18289pub enum NodeServerError {
18290 #[error("node endpoint must be a bounded absolute local transport path")]
18291 InvalidEndpoint,
18292 #[error("node access token must contain 1..=4096 bytes")]
18293 InvalidAccessToken,
18294 #[error("node HTTP observer must listen on a loopback address: {0}")]
18295 InvalidApiListen(std::net::SocketAddr),
18296 #[error("node browser bridge must listen on a loopback address: {0}")]
18297 InvalidBridgeListen(std::net::SocketAddr),
18298 #[error("node browser bridge token must contain 1..=4096 bytes")]
18299 InvalidBridgeToken,
18300 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18301 BridgeUnderlayRequiresBridgeListen,
18302 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18303 BridgeUnderlayRequiresBridgeToken,
18304 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18305 InvalidBridgeUnderlayToken,
18306 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18307 InvalidBridgeUnderlayKey,
18308 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18309 InvalidCallHome(std::net::SocketAddr),
18310 #[error("node could not reach its call-home relay: {0}")]
18311 CallHomeTransport(#[source] std::io::Error),
18312 #[error("node durable state path must be an absolute file path: {0}")]
18313 InvalidStatePath(String),
18314 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18315 InvalidHarnessMcpHelper,
18316 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18317 InvalidNetworkAllowlistCatalogPath(String),
18318 #[error("network allowlist catalog could not be read: {0}")]
18319 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18320 #[error("network allowlist catalog line {line}: {message}")]
18321 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18322 #[error("network allowlist catalog: {0}")]
18323 InvalidNetworkAllowlistCatalog(String),
18324 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18325 NetworkAllowlistCatalogCapacity { max: usize },
18326 #[error("the local state directory is unavailable; supply an explicit state path")]
18327 LocalStateDirectoryUnavailable,
18328 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18329 LocalRuntimeDirectoryUnavailable,
18330 #[error("node durable state failed: {0}")]
18331 DurableState(io::Error),
18332 #[error("node requires at least one configured workspace")]
18333 NoWorkspaces,
18334 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18335 InvalidWorkspaceRoot {
18336 workspace_id: WorkspaceId,
18337 path: String,
18338 message: String,
18339 },
18340 #[error("duplicate workspace ID: {0}")]
18341 DuplicateWorkspaceId(WorkspaceId),
18342 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18343 DuplicateWorkspaceRoot {
18344 first: WorkspaceId,
18345 second: WorkspaceId,
18346 root: String,
18347 },
18348 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18349 ManagedWorktreeProfileRequired(WorkspaceId),
18350 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18351 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18352 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18353 DuplicateManagedWorktreeProfile {
18354 workspace_id: WorkspaceId,
18355 profile_id: WorktreeProfileId,
18356 },
18357 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18358 ManagedWorktreeProfileCapacity {
18359 workspace_id: WorkspaceId,
18360 max: usize,
18361 },
18362 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18363 InvalidManagedWorktreeProfile {
18364 workspace_id: WorkspaceId,
18365 profile_id: WorktreeProfileId,
18366 message: String,
18367 },
18368 #[error("duplicate node environment profile: {0}")]
18369 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18370 #[error("node environment profile provider is unavailable: {0}")]
18371 EnvironmentProfileProviderUnavailable(AgentId),
18372 #[error("node environment profile capacity is {max}")]
18373 EnvironmentProfileCapacity { max: usize },
18374 #[error("node environment profiles reuse native profile ID '{0}'")]
18375 DuplicateNativeEnvironmentProfile(String),
18376 #[error("node native environment profile is invalid: {0}")]
18377 NativeEnvironmentProfile(String),
18378 #[error("node session-environment materialization root must be absolute")]
18379 InvalidSessionEnvironmentRoot,
18380 #[error("node session-environment materializer is unavailable")]
18381 SessionEnvironmentMaterializerRequired,
18382 #[error("node session-environment materializer failed to initialize")]
18383 SessionEnvironmentMaterializer,
18384 #[error("node bundle catalog is invalid: {0}")]
18385 BundleCatalog(String),
18386 #[error("node delivery store failed to initialize: {0}")]
18387 DeliveryStore(String),
18388 #[error("node context pack store failed to initialize")]
18389 ContextPackStore,
18390 #[error("active agent registry failed: {0}")]
18391 Registry(String),
18392 #[error("node provider contract manifest is invalid: {0}")]
18393 ProviderContractManifest(String),
18394 #[error("named pipe I/O failed: {0}")]
18395 Io(#[from] io::Error),
18396 #[error("node HTTP observer failed: {0}")]
18397 HttpApi(io::Error),
18398 #[error("node browser bridge failed: {0}")]
18399 BridgeApi(io::Error),
18400 #[error(transparent)]
18401 Frame(#[from] FrameError),
18402 #[error("node handshake failed: {0}")]
18403 Handshake(String),
18404 #[error("node authentication frame was not received before the bounded deadline")]
18405 AuthenticationTimedOut,
18406 #[error("node authentication primitive failed: {0}")]
18407 Authentication(String),
18408 #[error("node incarnation identity generation failed: {0}")]
18409 IncarnationIdentity(String),
18410 #[error("node pre-authentication limiter is closed")]
18411 PreauthClosed,
18412 #[error("node authenticated connection limit was reached")]
18413 AuthenticatedConnectionLimit,
18414 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18415 SignalStreamClosed,
18416 #[error("node stop-all dispatch failed: {0:?}")]
18417 ShutdownDispatch(NodeFailure),
18418 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18419 ShutdownTimedOut { active_native_sessions: usize },
18420}
18421
18422impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18423 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18424 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18425 match error {
18426 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18427 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18428 E::InvalidEntry { line, message } => {
18429 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18430 }
18431 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18432 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18433 }
18434 }
18435}
18436
18437#[cfg(test)]
18438mod agent_stream_blocked_tests {
18439 use super::*;
18440 use gate4agent_types::{
18441 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18442 ProviderSource,
18443 };
18444
18445 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18446 ProviderSource {
18447 family,
18448 binding: AdapterBinding::new(
18449 AdapterId::new(adapter).unwrap(),
18450 "fixture/v1",
18451 AdapterVerification::SyntheticFixture,
18452 )
18453 .unwrap(),
18454 }
18455 }
18456
18457 fn provider_control_event_at(
18458 family: AdapterFamily,
18459 adapter: &str,
18460 source_sequence: u64,
18461 provider_event: ProviderEvent,
18462 ) -> ControlEvent {
18463 ControlEvent {
18464 sequence: 41,
18465 command_id: None,
18466 instance_id: AgentInstanceId(7),
18467 generation: SessionGeneration(3),
18468 event: ControlEventKind::ProviderEvent {
18469 sequence: 8,
18470 source: provider_source(family, adapter),
18471 source_sequence,
18472 event: provider_event,
18473 },
18474 }
18475 }
18476
18477 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18478 provider_control_event_at(family, "fixture", 9, provider_event)
18479 }
18480
18481 fn denied_host_request(
18482 by: ProviderHostDecisionAuthority,
18483 reason: Option<String>,
18484 ) -> ControlEvent {
18485 provider_control_event_at(
18486 AdapterFamily::Acp,
18487 "claude-code",
18488 2,
18489 ProviderEvent::HostRequestObserved {
18490 method: "terminal/create".to_owned(),
18491 params_json: String::new(),
18492 decision: ProviderHostRequestDecision::Denied { by },
18493 outcome: ProviderHostRequestOutcome::Executed,
18494 reason,
18495 },
18496 )
18497 }
18498
18499 fn blocked(
18500 event: &ControlEvent,
18501 ) -> (
18502 Option<String>,
18503 String,
18504 BlockAuthorityV1,
18505 Option<String>,
18506 String,
18507 Option<String>,
18508 ) {
18509 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18510 chunk.validate().expect("minted chunk must validate");
18511 let AgentStreamChunkKindV1::Blocked {
18512 correlation_id,
18513 tool_class,
18514 authority,
18515 reason_kind,
18516 reason,
18517 help,
18518 } = chunk.kind
18519 else {
18520 panic!("expected AgentStreamChunkKindV1::Blocked");
18521 };
18522 (correlation_id, tool_class, authority, reason_kind, reason, help)
18523 }
18524
18525 #[test]
18529 fn the_enabled_provider_matrix_is_exact() {
18530 let registry = active_registry().unwrap();
18531 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18532 assert_eq!(
18533 providers
18534 .iter()
18535 .map(|contract| contract.provider.as_str())
18536 .collect::<Vec<_>>(),
18537 vec!["shell", "claude", "codex", "grok", "kimi"],
18539 );
18540 assert!(adapters.iter().all(|contract| {
18541 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18542 }));
18543 }
18544
18545 #[test]
18548 fn gate_denied_host_request_mints_a_blocked_chunk() {
18549 let gate_text =
18550 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18551 let event = denied_host_request(
18552 ProviderHostDecisionAuthority::Gate,
18553 Some(gate_text.to_owned()),
18554 );
18555 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18556 assert_eq!(chunk.source_sequence, 2);
18557 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18558 assert_eq!(authority, BlockAuthorityV1::HostGate);
18559 assert_eq!(reason, gate_text);
18560 assert_eq!(correlation_id, None);
18561 assert_eq!(reason_kind, None);
18562 assert_eq!(help, None);
18563 assert_eq!(tool_class, "Write");
18564 }
18565
18566 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.";
18567
18568 #[test]
18574 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18575 let event = provider_control_event(
18576 AdapterFamily::Acp,
18577 ProviderEvent::ToolCompleted {
18578 id: "toolu_9".to_owned(),
18579 output: CLASSIFIER_SAMPLE.to_owned(),
18580 is_error: true,
18581 duration_ms: None,
18582 agent_id: None,
18583 non_execution_kind: Some("permission-rule".to_owned()),
18584 },
18585 );
18586 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18587 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18588 assert_eq!(reason, "Blocked by classifier");
18589 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18590 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18591 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18592 }
18593
18594 #[test]
18599 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18600 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.";
18601 let event = provider_control_event(
18602 AdapterFamily::Acp,
18603 ProviderEvent::SessionEnded {
18604 result: "JSON-RPC error -32603: Internal error".to_owned(),
18605 cost_usd: None,
18606 is_error: true,
18607 stop_reason: Some(ProviderStopReason::ProviderError {
18608 code: -32603,
18609 message: message.to_owned(),
18610 vendor_code: Some("usageLimitExceeded".to_owned()),
18611 }),
18612 },
18613 );
18614 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18615 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18616 assert_eq!(reason, message);
18617 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18618 assert_eq!(tool_class, "Session");
18619 }
18620
18621 #[test]
18624 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18625 let event = provider_control_event(
18626 AdapterFamily::Acp,
18627 ProviderEvent::SessionEnded {
18628 result: "refusal".to_owned(),
18629 cost_usd: None,
18630 is_error: true,
18631 stop_reason: Some(ProviderStopReason::Refusal),
18632 },
18633 );
18634 let (_, _, authority, _, _, _) = blocked(&event);
18635 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18636 }
18637
18638 #[test]
18642 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18643 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18644 let event = provider_control_event(
18645 AdapterFamily::Acp,
18646 ProviderEvent::ToolCompleted {
18647 id: "toolu_1".to_owned(),
18648 output: "command exited with status 1".to_owned(),
18649 is_error: true,
18650 duration_ms: Some(12),
18651 agent_id: None,
18652 non_execution_kind,
18653 },
18654 );
18655 assert!(agent_stream_chunk(&event).is_none());
18656 }
18657 let narrative = provider_control_event(
18658 AdapterFamily::Acp,
18659 ProviderEvent::ToolCompleted {
18660 id: "toolu_2".to_owned(),
18661 output: "the harness blocked the write, approval needed".to_owned(),
18662 is_error: false,
18663 duration_ms: Some(3),
18664 agent_id: None,
18665 non_execution_kind: None,
18666 },
18667 );
18668 assert!(agent_stream_chunk(&narrative).is_none());
18669 }
18670
18671 #[test]
18675 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18676 for (decision, outcome) in [
18677 (
18678 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18679 ProviderHostRequestOutcome::Executed,
18680 ),
18681 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18682 (
18683 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18684 ProviderHostRequestOutcome::Failed {
18685 error: "terminal/create spawn failed: os error 3".to_owned(),
18686 },
18687 ),
18688 ] {
18689 let event = provider_control_event_at(
18690 AdapterFamily::Acp,
18691 "claude-code",
18692 3,
18693 ProviderEvent::HostRequestObserved {
18694 method: "fs/read_text_file".to_owned(),
18695 params_json: String::new(),
18696 decision: decision.clone(),
18697 outcome,
18698 reason: None,
18699 },
18700 );
18701 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18702 }
18703 }
18704
18705 #[test]
18709 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18710 for (by, expected_authority, expected_reason) in [
18711 (
18712 ProviderHostDecisionAuthority::Operator,
18713 BlockAuthorityV1::Operator,
18714 "declined by the operator",
18715 ),
18716 (
18717 ProviderHostDecisionAuthority::DeadlinePolicy,
18718 BlockAuthorityV1::HostDeadline,
18719 "denied by host policy after the operator deadline expired",
18720 ),
18721 ] {
18722 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18723 assert_eq!(authority, expected_authority);
18724 assert_eq!(reason, expected_reason);
18725 }
18726 }
18727
18728 #[test]
18732 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18733 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18734 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18735 ProviderHostDecisionAuthority::Policy,
18736 Some(oversized),
18737 ));
18738 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18739 }
18740}
18741
18742#[cfg(test)]
18743mod standalone_capability_platform_tests {
18744 use super::*;
18745
18746 #[test]
18747 fn standalone_workspace_capability_requires_windows_identity_guards() {
18748 let capabilities = baseline_capabilities().unwrap();
18749 let advertised = capabilities.iter().any(|capability| {
18750 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18751 });
18752 assert_eq!(advertised, cfg!(windows));
18753
18754 let request = NodeRequest::CreateStandaloneWorkspace {
18755 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18756 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18757 initial_branch: None,
18758 };
18759 assert_eq!(
18760 request_uses_unnegotiated_capability(&request, &capabilities),
18761 !cfg!(windows),
18762 );
18763 }
18764}
18765
18766
18767#[cfg(test)]
18768mod station_knobs_resolve_tests {
18769 use super::*;
18770 use gate4agent_catalog::EnvMutation;
18771 use gate4agent_runtime_native::{
18772 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18773 NativeLaunchProfileId,
18774 };
18775 use std::ffi::OsString;
18776 use std::sync::Arc;
18777
18778 struct EmptyEnvironmentResolver;
18779
18780 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18781 fn resolve_child_environment(
18782 &self,
18783 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18784 Ok(vec![EnvMutation {
18785 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18786 value: None,
18787 }])
18788 }
18789 }
18790
18791 fn agent(value: &str) -> AgentId {
18792 AgentId::new(value).unwrap()
18793 }
18794
18795 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18796 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18797 let profile_revision =
18798 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18799 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18800 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18801 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18802 provider: agent("claude"),
18803 mode: SessionMode::Pty,
18804 terminal_size: gate4agent_types::TerminalSize {
18805 rows: 24,
18806 columns: 80,
18807 },
18808 prompt: None,
18809 bundle_id: None,
18810 context_id: None,
18811 environment_profile_id: Some(profile_id.clone()),
18812 };
18813 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18814 let catalog = active_registry().unwrap();
18815 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18816 let control = runtime.native_launch_profile_control();
18817 let node_profile = NodeEnvironmentProfile::new(
18818 profile_id.clone(),
18819 profile_revision,
18820 agent("claude"),
18821 [NativeLaunchProfile::new(
18822 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18823 agent("claude"),
18824 TransportKind::Pty,
18825 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18826 Arc::new(EmptyEnvironmentResolver),
18827 )
18828 .unwrap()],
18829 )
18830 .unwrap();
18831 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18832 for native_profile in native_profiles {
18833 runtime.upsert_native_launch_profile(native_profile).unwrap();
18834 }
18835 let workspace = WorkspaceConfig::new(
18836 WorkspaceId::new("primary").unwrap(),
18837 std::env::current_dir().unwrap(),
18838 )
18839 .unwrap();
18840 let shared = NodeShared::new_with_incarnation(
18841 handle,
18842 "fixture-token".to_owned(),
18843 NodeId::new("node-station-knobs-test").unwrap(),
18844 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18845 vec![workspace],
18846 vec![agent("claude")],
18847 ProviderRuntimeStatuses::default(),
18848 None,
18849 Vec::new(),
18850 Vec::new(),
18851 spawn_profiles,
18852 Some(control),
18853 None,
18854 None,
18855 None,
18856 Vec::new(),
18857 Vec::new(),
18858 Vec::new(),
18859 Vec::new(),
18860 None,
18861 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18862 );
18863 shared
18864 .environment_profiles
18865 .write()
18866 .unwrap_or_else(|poisoned| poisoned.into_inner())
18867 .insert(binding.id.clone(), binding);
18868 (shared, spawn_defaults)
18869 }
18870
18871 #[test]
18874 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18875 let (shared, defaults) = station_knobs_fixture();
18876 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18877 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18878 let spec = crate::protocol::SpawnSpec {
18879 target: crate::protocol::SpawnTarget {
18880 node_id: shared.node_id.clone(),
18881 workspace_id: WorkspaceId::new("primary").unwrap(),
18882 worktree_id: None,
18883 },
18884 profile_id: defaults.profile_id.clone(),
18885 expected_profile_revision: defaults.revision.clone(),
18886 overrides: crate::protocol::SpawnOverrides {
18887 network_allowlist: Some(unknown),
18888 browser_profile_id: Some(browser),
18889 ..crate::protocol::SpawnOverrides::default()
18890 },
18891 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18892 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18893 "station-knobs-unknown-allowlist",
18894 )
18895 .unwrap(),
18896 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18897 };
18898 let resolved = spec.resolve(&defaults).unwrap();
18899 let failure = shared
18900 .resolve_environment_profile(&resolved)
18901 .expect_err("unknown allowlist must refuse");
18902 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18903 }
18904
18905 #[test]
18911 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18912 let (shared, defaults) = station_knobs_fixture();
18913 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18914 #[cfg(not(feature = "dig2-station-probe"))]
18915 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18916 shared
18917 .network_allowlist_catalog
18918 .write()
18919 .unwrap_or_else(|poisoned| poisoned.into_inner())
18920 .insert_id(allowlist.clone())
18921 .unwrap();
18922 let spec = crate::protocol::SpawnSpec {
18923 target: crate::protocol::SpawnTarget {
18924 node_id: shared.node_id.clone(),
18925 workspace_id: WorkspaceId::new("primary").unwrap(),
18926 worktree_id: None,
18927 },
18928 profile_id: defaults.profile_id.clone(),
18929 expected_profile_revision: defaults.revision.clone(),
18930 overrides: crate::protocol::SpawnOverrides {
18931 network_allowlist: Some(allowlist.clone()),
18932 #[cfg(not(feature = "dig2-station-probe"))]
18933 browser_profile_id: Some(browser.clone()),
18934 ..crate::protocol::SpawnOverrides::default()
18935 },
18936 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18937 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18938 .unwrap(),
18939 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18940 };
18941 let resolved = spec.resolve(&defaults).unwrap();
18942 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18943 #[cfg(not(feature = "dig2-station-probe"))]
18944 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18945 let receipt = shared
18946 .resolve_environment_profile(&resolved)
18947 .expect("resolve ok")
18948 .expect("env profile present");
18949 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18950 #[cfg(not(feature = "dig2-station-probe"))]
18951 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18952 #[cfg(feature = "dig2-station-probe")]
18953 assert_eq!(receipt.browser_profile_id, None);
18954 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18955
18956 let omitted = crate::protocol::SpawnSpec {
18957 overrides: crate::protocol::SpawnOverrides::default(),
18958 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18959 .unwrap(),
18960 ..spec
18961 };
18962 let resolved_omit = omitted.resolve(&defaults).unwrap();
18963 let bare = shared
18964 .resolve_environment_profile(&resolved_omit)
18965 .expect("resolve ok")
18966 .expect("env profile present");
18967 assert_eq!(bare.network_allowlist, None);
18968 assert_eq!(bare.browser_profile_id, None);
18969 }
18970
18971 #[cfg(feature = "dig2-station-probe")]
18976 #[test]
18977 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18978 let (shared, defaults) = station_knobs_fixture();
18979 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18980 let spec = crate::protocol::SpawnSpec {
18981 target: crate::protocol::SpawnTarget {
18982 node_id: shared.node_id.clone(),
18983 workspace_id: WorkspaceId::new("primary").unwrap(),
18984 worktree_id: None,
18985 },
18986 profile_id: defaults.profile_id.clone(),
18987 expected_profile_revision: defaults.revision.clone(),
18988 overrides: crate::protocol::SpawnOverrides {
18989 browser_profile_id: Some(browser),
18990 ..crate::protocol::SpawnOverrides::default()
18991 },
18992 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18993 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18994 "station-knobs-probe-refuse",
18995 )
18996 .unwrap(),
18997 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18998 };
18999 let resolved = spec.resolve(&defaults).unwrap();
19000 let failure = shared
19001 .resolve_environment_profile(&resolved)
19002 .expect_err("browser_profile with failing probe must refuse");
19003 #[cfg(windows)]
19004 {
19005 assert!(
19006 matches!(
19007 failure.code,
19008 NodeFailureCode::BrowserStationUnreachable
19009 | NodeFailureCode::BrowserStationProbeUnavailable
19010 ),
19011 "unexpected code {:?}",
19012 failure.code
19013 );
19014 }
19015 #[cfg(not(windows))]
19016 {
19017 assert_eq!(
19018 failure.code,
19019 NodeFailureCode::BrowserStationProbeUnavailable
19020 );
19021 }
19022 }
19023
19024 #[cfg(feature = "dig2-station-probe")]
19026 #[test]
19027 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19028 let (shared, defaults) = station_knobs_fixture();
19029 let spec = crate::protocol::SpawnSpec {
19030 target: crate::protocol::SpawnTarget {
19031 node_id: shared.node_id.clone(),
19032 workspace_id: WorkspaceId::new("primary").unwrap(),
19033 worktree_id: None,
19034 },
19035 profile_id: defaults.profile_id.clone(),
19036 expected_profile_revision: defaults.revision.clone(),
19037 overrides: crate::protocol::SpawnOverrides::default(),
19038 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19039 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19040 "station-knobs-probe-omit",
19041 )
19042 .unwrap(),
19043 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19044 };
19045 let resolved = spec.resolve(&defaults).unwrap();
19046 let receipt = shared
19047 .resolve_environment_profile(&resolved)
19048 .expect("omit browser_profile must not probe")
19049 .expect("env profile present");
19050 assert_eq!(receipt.browser_profile_id, None);
19051 }
19052
19053 #[cfg(feature = "dig2-station-probe")]
19057 #[test]
19058 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19059 let (shared, _defaults) = station_knobs_fixture();
19060 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19061 let workspace_id = WorkspaceId::new("primary").unwrap();
19062 let address_a = SessionAddress {
19063 workspace_id: workspace_id.clone(),
19064 session: SessionKey {
19065 instance_id: AgentInstanceId(101),
19066 generation: SessionGeneration(1),
19067 },
19068 };
19069 let address_b = SessionAddress {
19070 workspace_id: workspace_id.clone(),
19071 session: SessionKey {
19072 instance_id: AgentInstanceId(102),
19073 generation: SessionGeneration(1),
19074 },
19075 };
19076 let guard_a = crate::dig2_station_lease::acquire_lease(
19077 &shared.browser_station_leases,
19078 profile.clone(),
19079 address_a.session.instance_id,
19080 1,
19081 )
19082 .expect("first lease");
19083 let busy = crate::dig2_station_lease::acquire_lease(
19084 &shared.browser_station_leases,
19085 profile.clone(),
19086 address_b.session.instance_id,
19087 2,
19088 )
19089 .expect_err("second holder must refuse");
19090 assert!(matches!(
19091 busy,
19092 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19093 ));
19094 shared.bind_session_with_policy(
19096 &address_a,
19097 ProviderRuntimePolicy::raw_pty(),
19098 None,
19099 );
19100 guard_a.retain();
19101 assert!(shared.remove_binding(&address_a).is_some());
19102 crate::dig2_station_lease::acquire_lease(
19103 &shared.browser_station_leases,
19104 profile,
19105 address_b.session.instance_id,
19106 3,
19107 )
19108 .expect("reacquire after clear")
19109 .retain();
19110 assert!(
19111 crate::dig2_station_lease::release_lease_for_holder(
19112 &shared.browser_station_leases,
19113 address_b.session.instance_id,
19114 )
19115 );
19116 }
19117
19118 #[cfg(not(feature = "dig2-station-probe"))]
19121 #[test]
19122 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19123 let (shared, defaults) = station_knobs_fixture();
19124 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19125 let spec = crate::protocol::SpawnSpec {
19126 target: crate::protocol::SpawnTarget {
19127 node_id: shared.node_id.clone(),
19128 workspace_id: WorkspaceId::new("primary").unwrap(),
19129 worktree_id: None,
19130 },
19131 profile_id: defaults.profile_id.clone(),
19132 expected_profile_revision: defaults.revision.clone(),
19133 overrides: crate::protocol::SpawnOverrides {
19134 browser_profile_id: Some(browser.clone()),
19135 ..crate::protocol::SpawnOverrides::default()
19136 },
19137 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19138 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19139 .unwrap(),
19140 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19141 };
19142 let resolved = spec.resolve(&defaults).unwrap();
19143 let receipt = shared
19144 .resolve_environment_profile(&resolved)
19145 .expect("stub resolve")
19146 .expect("env profile");
19147 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19148 }
19149
19150
19151 #[test]
19154 fn network_allowlist_catalog_loads_from_temp_file() {
19155 let unique = std::time::SystemTime::now()
19156 .duration_since(std::time::UNIX_EPOCH)
19157 .unwrap()
19158 .as_nanos();
19159 let path = std::env::temp_dir().join(format!(
19160 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19161 std::process::id(),
19162 ));
19163 std::fs::write(
19164 &path,
19165 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19166 )
19167 .unwrap();
19168 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19169 .expect("temp catalog file must load");
19170 let _ = std::fs::remove_file(&path);
19171 assert_eq!(loaded.len(), 2);
19172 let (shared, defaults) = station_knobs_fixture();
19173 {
19174 let mut catalog = shared
19175 .network_allowlist_catalog
19176 .write()
19177 .unwrap_or_else(|poisoned| poisoned.into_inner());
19178 *catalog = loaded;
19179 }
19180 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19181 let spec = crate::protocol::SpawnSpec {
19182 target: crate::protocol::SpawnTarget {
19183 node_id: shared.node_id.clone(),
19184 workspace_id: WorkspaceId::new("primary").unwrap(),
19185 worktree_id: None,
19186 },
19187 profile_id: defaults.profile_id.clone(),
19188 expected_profile_revision: defaults.revision.clone(),
19189 overrides: crate::protocol::SpawnOverrides {
19190 network_allowlist: Some(allowlist.clone()),
19191 ..crate::protocol::SpawnOverrides::default()
19192 },
19193 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19194 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19195 "station-knobs-catalog-file",
19196 )
19197 .unwrap(),
19198 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19199 };
19200 let resolved = spec.resolve(&defaults).unwrap();
19201 let receipt = shared
19202 .resolve_environment_profile(&resolved)
19203 .expect("registered via file load")
19204 .expect("env profile present");
19205 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19206 }
19207
19208 #[test]
19211 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19212 let (shared, _defaults) = station_knobs_fixture();
19213 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19214 shared
19215 .network_allowlist_catalog
19216 .write()
19217 .unwrap_or_else(|poisoned| poisoned.into_inner())
19218 .insert_id(allowlist.clone())
19219 .unwrap();
19220 let snapshot = shared.snapshot();
19221 let inventory = snapshot
19222 .launch_inventory
19223 .expect("launch inventory present");
19224 let ids = inventory
19225 .network_allowlists
19226 .expect("non-empty catalog surfaces ids");
19227 assert_eq!(ids, vec![allowlist]);
19228 }
19229
19230 #[test]
19234 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19235 let (shared, defaults) = station_knobs_fixture();
19236 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19237 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19238 id: allowlist.clone(),
19239 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19240 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19241 peer: "127.0.0.1:443".to_owned(),
19242 }],
19243 provider_native: Some(
19244 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19245 codex_network_access: Some(true),
19246 },
19247 ),
19248 };
19249 shared
19250 .network_allowlist_catalog
19251 .write()
19252 .unwrap_or_else(|poisoned| poisoned.into_inner())
19253 .insert(entry)
19254 .unwrap();
19255 let spec = crate::protocol::SpawnSpec {
19256 target: crate::protocol::SpawnTarget {
19257 node_id: shared.node_id.clone(),
19258 workspace_id: WorkspaceId::new("primary").unwrap(),
19259 worktree_id: None,
19260 },
19261 profile_id: defaults.profile_id.clone(),
19262 expected_profile_revision: defaults.revision.clone(),
19263 overrides: crate::protocol::SpawnOverrides {
19264 network_allowlist: Some(allowlist),
19265 ..crate::protocol::SpawnOverrides::default()
19266 },
19267 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19268 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19269 "station-knobs-unsupported-mapping",
19270 )
19271 .unwrap(),
19272 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19273 };
19274 let resolved = spec.resolve(&defaults).unwrap();
19275 assert_eq!(resolved.provider.as_str(), "claude");
19276 let failure = shared
19277 .resolve_environment_profile(&resolved)
19278 .expect_err("claude + codex_network_access must refuse");
19279 assert_eq!(
19280 failure.code,
19281 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19282 );
19283 }
19284
19285 #[test]
19289 fn resolve_codex_network_access_overlay_moderate_only() {
19290 let (shared, defaults) = station_knobs_fixture();
19291 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19292 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19293 id: allowlist.clone(),
19294 permits: Vec::new(),
19295 provider_native: Some(
19296 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19297 codex_network_access: Some(true),
19298 },
19299 ),
19300 };
19301 shared
19302 .network_allowlist_catalog
19303 .write()
19304 .unwrap_or_else(|poisoned| poisoned.into_inner())
19305 .insert(entry)
19306 .unwrap();
19307
19308 let overlay = shared
19309 .resolve_codex_network_access_overlay_args(
19310 &agent("codex"),
19311 SessionMode::Pty,
19312 ApprovalLevel::Moderate,
19313 Some(&allowlist),
19314 )
19315 .expect("Moderate Codex maps network_access");
19316 assert_eq!(
19317 overlay,
19318 vec![
19319 std::ffi::OsString::from("-c"),
19320 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19321 ]
19322 );
19323
19324 let acp = shared
19325 .resolve_codex_network_access_overlay_args(
19326 &agent("codex"),
19327 SessionMode::Acp,
19328 ApprovalLevel::Moderate,
19329 Some(&allowlist),
19330 )
19331 .unwrap();
19332 assert_eq!(acp, overlay);
19333
19334 let inline = shared
19335 .resolve_codex_network_access_overlay_args(
19336 &agent("codex"),
19337 SessionMode::Inline,
19338 ApprovalLevel::Moderate,
19339 Some(&allowlist),
19340 )
19341 .unwrap_err();
19342 assert_eq!(
19343 inline.code,
19344 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19345 );
19346
19347 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19348 let spec = crate::protocol::SpawnSpec {
19349 target: crate::protocol::SpawnTarget {
19350 node_id: shared.node_id.clone(),
19351 workspace_id: WorkspaceId::new("primary").unwrap(),
19352 worktree_id: None,
19353 },
19354 profile_id: defaults.profile_id.clone(),
19355 expected_profile_revision: defaults.revision.clone(),
19356 overrides: crate::protocol::SpawnOverrides {
19357 provider: crate::protocol::SpawnOverride::Set {
19358 value: agent("codex"),
19359 },
19360 approval_level: Some(level),
19361 network_allowlist: Some(allowlist.clone()),
19362 ..crate::protocol::SpawnOverrides::default()
19363 },
19364 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19365 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19366 "codex-net-refuse-{}",
19367 match level {
19368 ApprovalLevel::ReadOnly => "ro",
19369 ApprovalLevel::FullAuto => "fa",
19370 _ => "other",
19371 }
19372 ))
19373 .unwrap(),
19374 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19375 };
19376 let resolved = spec.resolve(&defaults).unwrap();
19377 let failure = shared
19378 .resolve_environment_profile(&resolved)
19379 .expect_err("RO/FullAuto must refuse");
19380 assert_eq!(
19381 failure.code,
19382 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19383 );
19384 }
19385 }
19386
19387 #[test]
19391 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19392 let (shared, _defaults) = station_knobs_fixture();
19393 let allowlist =
19394 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19395 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19396 id: allowlist.clone(),
19397 permits: vec![
19398 crate::network_allowlist_catalog::NetworkPermitSketch {
19399 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19400 peer: "github.com:443".to_owned(),
19401 },
19402 crate::network_allowlist_catalog::NetworkPermitSketch {
19403 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19404 peer: "*.npmjs.org".to_owned(),
19405 },
19406 ],
19407 provider_native: None,
19408 };
19409 shared
19410 .network_allowlist_catalog
19411 .write()
19412 .unwrap_or_else(|poisoned| poisoned.into_inner())
19413 .insert(entry)
19414 .unwrap();
19415
19416 let unique = std::time::SystemTime::now()
19417 .duration_since(std::time::UNIX_EPOCH)
19418 .unwrap()
19419 .as_nanos();
19420 let provider_home = std::env::temp_dir().join(format!(
19421 "gate4agent-claude-provider-home-{}-{unique}",
19422 std::process::id(),
19423 ));
19424 std::fs::create_dir_all(&provider_home).unwrap();
19425
19426 let overlay = shared
19427 .resolve_network_allowlist_overlay_args(
19428 &agent("claude"),
19429 SessionMode::Pty,
19430 ApprovalLevel::Moderate,
19431 Some(&allowlist),
19432 Some(provider_home.as_path()),
19433 )
19434 .expect("Claude PTY settings overlay on unix");
19435 let settings_path = provider_home.join(
19436 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19437 );
19438 assert_eq!(
19439 overlay,
19440 vec![
19441 std::ffi::OsString::from("--settings"),
19442 std::ffi::OsString::from(settings_path.as_os_str()),
19443 ]
19444 );
19445 let body = std::fs::read_to_string(&settings_path).unwrap();
19446 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19447 assert_eq!(value["sandbox"]["enabled"], true);
19448 assert_eq!(
19449 value["sandbox"]["network"]["allowedDomains"],
19450 serde_json::json!(["github.com", "*.npmjs.org"])
19451 );
19452 assert!(settings_path.starts_with(&provider_home));
19453
19454 let acp = shared
19455 .resolve_network_allowlist_overlay_args(
19456 &agent("claude"),
19457 SessionMode::Acp,
19458 ApprovalLevel::Moderate,
19459 Some(&allowlist),
19460 Some(provider_home.as_path()),
19461 )
19462 .expect_err("Claude ACP must refuse settings overlay");
19463 assert_eq!(
19464 acp.code,
19465 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19466 );
19467
19468 let no_home = shared
19469 .resolve_network_allowlist_overlay_args(
19470 &agent("claude"),
19471 SessionMode::Pty,
19472 ApprovalLevel::Moderate,
19473 Some(&allowlist),
19474 None,
19475 )
19476 .expect_err("Claude overlay requires ProviderHome");
19477 assert_eq!(
19478 no_home.code,
19479 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19480 );
19481
19482 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19483 shared
19484 .network_allowlist_catalog
19485 .write()
19486 .unwrap_or_else(|poisoned| poisoned.into_inner())
19487 .insert_id(bare.clone())
19488 .unwrap();
19489 let empty = shared
19490 .resolve_network_allowlist_overlay_args(
19491 &agent("claude"),
19492 SessionMode::Pty,
19493 ApprovalLevel::Moderate,
19494 Some(&bare),
19495 Some(provider_home.as_path()),
19496 )
19497 .unwrap();
19498 assert!(empty.is_empty());
19499
19500 let _ = std::fs::remove_dir_all(&provider_home);
19501 }
19502
19503 #[test]
19505 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19506 let (shared, _defaults) = station_knobs_fixture();
19507 let allowlist =
19508 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19509 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19510 id: allowlist.clone(),
19511 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19512 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19513 peer: "example.com:443".to_owned(),
19514 }],
19515 provider_native: None,
19516 };
19517 shared
19518 .network_allowlist_catalog
19519 .write()
19520 .unwrap_or_else(|poisoned| poisoned.into_inner())
19521 .insert(entry)
19522 .unwrap();
19523 for provider in ["kimi", "grok"] {
19524 let overlay = shared
19525 .resolve_network_allowlist_overlay_args(
19526 &agent(provider),
19527 SessionMode::Pty,
19528 ApprovalLevel::Moderate,
19529 Some(&allowlist),
19530 None,
19531 )
19532 .unwrap();
19533 assert!(
19534 overlay.is_empty(),
19535 "{provider} must not invent network argv from permits"
19536 );
19537 }
19538 }
19539
19540 #[test]
19542 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19543 let unique = std::time::SystemTime::now()
19544 .duration_since(std::time::UNIX_EPOCH)
19545 .unwrap()
19546 .as_nanos();
19547 let path = std::env::temp_dir().join(format!(
19548 "gate4agent-node-allowlist-json-{}-{unique}.json",
19549 std::process::id(),
19550 ));
19551 std::fs::write(
19552 &path,
19553 r#"{
19554 "schema_version": 2,
19555 "entries": [
19556 {
19557 "id": "egress-default",
19558 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19559 },
19560 { "id": "lab-egress" }
19561 ]
19562 }"#,
19563 )
19564 .unwrap();
19565 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19566 .expect("json v2 catalog must load");
19567 let _ = std::fs::remove_file(&path);
19568 assert_eq!(loaded.len(), 2);
19569 let entry = loaded
19570 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19571 .unwrap();
19572 assert_eq!(entry.permits.len(), 1);
19573 let (shared, defaults) = station_knobs_fixture();
19574 {
19575 let mut catalog = shared
19576 .network_allowlist_catalog
19577 .write()
19578 .unwrap_or_else(|poisoned| poisoned.into_inner());
19579 *catalog = loaded;
19580 }
19581 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19582 let spec = crate::protocol::SpawnSpec {
19583 target: crate::protocol::SpawnTarget {
19584 node_id: shared.node_id.clone(),
19585 workspace_id: WorkspaceId::new("primary").unwrap(),
19586 worktree_id: None,
19587 },
19588 profile_id: defaults.profile_id.clone(),
19589 expected_profile_revision: defaults.revision.clone(),
19590 overrides: crate::protocol::SpawnOverrides {
19591 network_allowlist: Some(allowlist.clone()),
19592 ..crate::protocol::SpawnOverrides::default()
19593 },
19594 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19595 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19596 "station-knobs-catalog-json",
19597 )
19598 .unwrap(),
19599 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19600 };
19601 let resolved = spec.resolve(&defaults).unwrap();
19602 let receipt = shared
19603 .resolve_environment_profile(&resolved)
19604 .expect("permits-only entry ok for claude")
19605 .expect("env profile present");
19606 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19607 let inventory = shared
19608 .snapshot()
19609 .launch_inventory
19610 .expect("launch inventory present");
19611 let ids = inventory.network_allowlists.expect("ids surface");
19612 assert_eq!(
19613 ids,
19614 vec![
19615 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19616 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19617 ]
19618 );
19619 }
19620
19621
19622}
19623
19624
19625#[cfg(test)]
19626mod workspace_environment_collision_tests {
19627 use super::*;
19628 use crate::session_environment::{
19629 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19630 };
19631 use gate4agent_catalog::EnvMutation;
19632 use gate4agent_runtime_native::{
19633 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19634 NativeLaunchProfileId,
19635 };
19636 use std::ffi::OsString;
19637 use std::sync::Arc;
19638
19639 struct EmptyEnvironmentResolver;
19640
19641 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19642 fn resolve_child_environment(
19643 &self,
19644 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19645 Ok(vec![EnvMutation {
19646 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19647 value: None,
19648 }])
19649 }
19650 }
19651
19652 fn agent(value: &str) -> AgentId {
19653 AgentId::new(value).unwrap()
19654 }
19655
19656 fn claude_config_dir_collision_fixture() -> (
19658 NodeShared,
19659 ResolvedEnvironmentProfileReceipt,
19660 ResolvedEnvironmentProfileReceipt,
19661 ) {
19662 let catalog = active_registry().unwrap();
19663 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19664 let control = runtime.native_launch_profile_control();
19665 let workspace = WorkspaceConfig::new(
19666 WorkspaceId::new("primary").unwrap(),
19667 std::env::current_dir().unwrap(),
19668 )
19669 .unwrap();
19670 let shared = NodeShared::new_with_incarnation(
19671 handle,
19672 "fixture-token".to_owned(),
19673 NodeId::new("node-env-collision-test").unwrap(),
19674 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19675 vec![workspace],
19676 vec![agent("claude")],
19677 ProviderRuntimeStatuses::default(),
19678 None,
19679 Vec::new(),
19680 Vec::new(),
19681 SpawnProfileRegistry::default(),
19682 Some(control),
19683 None,
19684 None,
19685 None,
19686 Vec::new(),
19687 Vec::new(),
19688 Vec::new(),
19689 Vec::new(),
19690 None,
19691 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19692 );
19693
19694 let mut receipts = Vec::new();
19695 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19696 let profile_id =
19697 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19698 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19699 "claude-config-dir-{suffix}-r1"
19700 ))
19701 .unwrap();
19702 let materialization = NodeSessionMaterializationProfile::new(
19703 Vec::new(),
19704 vec![NodeSessionPathBinding::new(
19705 "CLAUDE_CONFIG_DIR",
19706 NodeSessionPathClass::ProviderHome,
19707 )
19708 .unwrap()],
19709 Vec::new(),
19710 )
19711 .unwrap();
19712 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19713 profile_id.clone(),
19714 profile_revision.clone(),
19715 agent("claude"),
19716 [NativeLaunchProfile::new(
19717 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19718 .unwrap(),
19719 agent("claude"),
19720 TransportKind::Pty,
19721 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19722 Arc::new(EmptyEnvironmentResolver),
19723 )
19724 .unwrap()],
19725 Some(materialization),
19726 )
19727 .unwrap();
19728 let (binding, native_profiles, materialization) = node_profile.into_parts();
19729 for native_profile in native_profiles {
19730 runtime.upsert_native_launch_profile(native_profile).unwrap();
19731 }
19732 shared
19733 .environment_profiles
19734 .write()
19735 .unwrap_or_else(|poisoned| poisoned.into_inner())
19736 .insert(binding.id.clone(), binding);
19737 shared
19738 .environment_materialization_profiles
19739 .write()
19740 .unwrap_or_else(|poisoned| poisoned.into_inner())
19741 .insert(profile_id.clone(), materialization.unwrap());
19742 receipts.push(ResolvedEnvironmentProfileReceipt {
19743 profile_id,
19744 profile_revision,
19745 network_allowlist: None,
19746 browser_profile_id: None,
19747 });
19748 }
19749 let profile_b = receipts.pop().unwrap();
19750 let profile_a = receipts.pop().unwrap();
19751 (shared, profile_a, profile_b)
19752 }
19753
19754 #[test]
19755 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19756 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19757 let primary = WorkspaceId::new("primary").unwrap();
19758 let worktree = WorkspaceId::new("worktree-b").unwrap();
19759 let address = SessionAddress {
19760 workspace_id: primary.clone(),
19761 session: SessionKey {
19762 instance_id: AgentInstanceId(41),
19763 generation: SessionGeneration(1),
19764 },
19765 };
19766 shared.bind_session_with_policy(
19767 &address,
19768 ProviderRuntimePolicy::raw_pty(),
19769 Some(profile_a.clone()),
19770 );
19771
19772 let refused = shared
19773 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19774 .unwrap_err();
19775 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19776
19777 let refused_none = shared
19779 .ensure_workspace_environment_profile_compatible(&primary, None)
19780 .unwrap_err();
19781 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19782
19783 shared
19785 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19786 .unwrap();
19787
19788 shared
19790 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19791 .unwrap();
19792 }
19793
19794 #[tokio::test]
19795 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19796 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19797 let primary = WorkspaceId::new("primary").unwrap();
19798 let address = SessionAddress {
19799 workspace_id: primary.clone(),
19800 session: SessionKey {
19801 instance_id: AgentInstanceId(41),
19802 generation: SessionGeneration(1),
19803 },
19804 };
19805 shared.bind_session_with_policy(
19806 &address,
19807 ProviderRuntimePolicy::raw_pty(),
19808 Some(profile_a),
19809 );
19810
19811 let failure = shared
19812 .spawn_session_with_deadline(
19813 primary,
19814 agent("claude"),
19815 SessionMode::Pty,
19816 gate4agent_types::TerminalSize {
19817 rows: 24,
19818 columns: 80,
19819 },
19820 None,
19821 Some(profile_b),
19822 None,
19823 None,
19824 None,
19825 None,
19826 SpawnRecordPolicy::ProviderIdentityOnly,
19827 Some(Instant::now() + Duration::from_secs(30)),
19828 &[],
19829 None,
19830 ApprovalLevel::default(),
19831 None,
19832 None,
19833 )
19834 .await
19835 .unwrap_err();
19836 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19837 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19839 assert!(shared.handle.snapshot().sessions.is_empty());
19840 }
19841}
19842
19843#[cfg(test)]
19844mod spawn_characterization_tests {
19845 use super::*;
19846 use crate::session_environment::{
19847 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19848 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19849 NodeSessionPathClass,
19850 };
19851 use gate4agent_catalog::EnvMutation;
19852 use gate4agent_runtime_native::{
19853 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19854 NativeLaunchProfile, NativeLaunchProfileId,
19855 };
19856 use std::ffi::OsString;
19857 use std::sync::Arc;
19858
19859 struct EmptyEnvironmentResolver;
19860
19861 struct FixtureSecretResolver {
19862 deny: Arc<AtomicBool>,
19863 }
19864
19865 impl NodeSecretResolver for FixtureSecretResolver {
19866 fn resolve(
19867 &self,
19868 _reference: &NodeSecretReference,
19869 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19870 if self.deny.load(Ordering::Acquire) {
19871 Err(NodeSecretResolveError::Denied)
19872 } else {
19873 NodeSecretValue::text("fixture-secret")
19874 .map_err(|_| NodeSecretResolveError::Unavailable)
19875 }
19876 }
19877 }
19878
19879 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19880 fn resolve_child_environment(
19881 &self,
19882 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19883 Ok(vec![EnvMutation {
19884 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19885 value: None,
19886 }])
19887 }
19888 }
19889
19890 fn agent(value: &str) -> AgentId {
19891 AgentId::new(value).unwrap()
19892 }
19893
19894 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19895 let unique = std::time::SystemTime::now()
19896 .duration_since(std::time::UNIX_EPOCH)
19897 .unwrap()
19898 .as_nanos();
19899 std::env::temp_dir().join(format!(
19900 "gate4agent-node-{label}-{}-{unique}",
19901 std::process::id(),
19902 ))
19903 }
19904
19905 fn materialization_test_shared(
19906 deny_secret: bool,
19907 ) -> (
19908 NodeShared,
19909 NativeRuntime,
19910 ResolvedEnvironmentProfileReceipt,
19911 PathBuf,
19912 Arc<AtomicBool>,
19913 ) {
19914 let workspace = WorkspaceConfig::new(
19915 WorkspaceId::new("primary").unwrap(),
19916 std::env::current_dir().unwrap(),
19917 )
19918 .unwrap();
19919 materialization_test_shared_with_workspace(deny_secret, workspace)
19920 }
19921
19922 fn materialization_test_shared_with_workspace(
19923 deny_secret: bool,
19924 workspace: WorkspaceConfig,
19925 ) -> (
19926 NodeShared,
19927 NativeRuntime,
19928 ResolvedEnvironmentProfileReceipt,
19929 PathBuf,
19930 Arc<AtomicBool>,
19931 ) {
19932 let root = temporary_workspace_root(if deny_secret {
19933 "materialization-denied"
19934 } else {
19935 "materialization-ready"
19936 });
19937 std::fs::create_dir_all(&root).unwrap();
19938 let materialization_root = root.join("session-environments");
19939 let deny = Arc::new(AtomicBool::new(deny_secret));
19940 let resolver = Arc::new(FixtureSecretResolver {
19941 deny: Arc::clone(&deny),
19942 });
19943 let materializer = SessionEnvironmentMaterializer::new(
19944 materialization_root,
19945 resolver,
19946 )
19947 .unwrap();
19948 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19949 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19950 "materialized-claude-r1",
19951 )
19952 .unwrap();
19953 let materialization = NodeSessionMaterializationProfile::new(
19954 vec![NodeSessionEnvironmentMutation::SetSecret {
19955 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19956 reference: NodeSecretReference::new("fixture-token").unwrap(),
19957 }],
19958 vec![NodeSessionPathBinding::new(
19959 "GATE4AGENT_PROVIDER_HOME",
19960 NodeSessionPathClass::ProviderHome,
19961 )
19962 .unwrap()],
19963 vec![NodeSessionFile::secret(
19964 NodeSessionPathClass::Config,
19965 "auth/token",
19966 NodeSecretReference::new("fixture-token").unwrap(),
19967 )
19968 .unwrap()],
19969 )
19970 .unwrap();
19971 let catalog = active_registry().unwrap();
19972 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19973 let control = runtime.native_launch_profile_control();
19974 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19975 profile_id.clone(),
19976 profile_revision.clone(),
19977 agent("claude"),
19978 [NativeLaunchProfile::new(
19979 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19980 agent("claude"),
19981 TransportKind::Pty,
19982 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19983 Arc::new(EmptyEnvironmentResolver),
19984 )
19985 .unwrap()],
19986 Some(materialization),
19987 )
19988 .unwrap();
19989 let (binding, native_profiles, materialization) = node_profile.into_parts();
19990 for native_profile in native_profiles {
19991 runtime.upsert_native_launch_profile(native_profile).unwrap();
19992 }
19993 let shared = NodeShared::new_with_incarnation(
19994 handle,
19995 "fixture-token".to_owned(),
19996 NodeId::new("node-materialization-test").unwrap(),
19997 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19998 vec![workspace],
19999 vec![agent("claude")],
20000 ProviderRuntimeStatuses::default(),
20001 None,
20002 Vec::new(),
20003 Vec::new(),
20004 SpawnProfileRegistry::default(),
20005 Some(control),
20006 None,
20007 Some(materializer),
20008 None,
20009 Vec::new(),
20010 Vec::new(),
20011 Vec::new(),
20012 Vec::new(),
20013 None,
20014 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20015 );
20016 shared
20017 .environment_profiles
20018 .write()
20019 .unwrap_or_else(|poisoned| poisoned.into_inner())
20020 .insert(binding.id.clone(), binding);
20021 shared
20022 .environment_materialization_profiles
20023 .write()
20024 .unwrap_or_else(|poisoned| poisoned.into_inner())
20025 .insert(profile_id.clone(), materialization.unwrap());
20026 (
20027 shared,
20028 runtime,
20029 ResolvedEnvironmentProfileReceipt {
20030 profile_id,
20031 profile_revision,
20032 network_allowlist: None,
20033 browser_profile_id: None,
20034 },
20035 root,
20036 deny,
20037 )
20038 }
20039
20040
20041 async fn characterization_spawn(
20044 shared: &NodeShared,
20045 mode: SessionMode,
20046 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20047 required: &[ProviderRuntimeCapability],
20048 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20049 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20050 shared
20051 .spawn_session_with_deadline(
20052 WorkspaceId::new("primary").unwrap(),
20053 agent("claude"),
20054 mode,
20055 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20056 None,
20057 environment_profile,
20058 None,
20059 None,
20060 None,
20061 Some(ProviderRuntimePolicy::raw_pty()),
20062 SpawnRecordPolicy::ProviderIdentityOnly,
20063 Some(Instant::now() + Duration::from_secs(30)),
20064 required,
20065 harness_mcp,
20066 ApprovalLevel::default(),
20067 None,
20068 None,
20069 )
20070 .await
20071 }
20072
20073 fn assert_no_session_state(shared: &NodeShared) {
20074 assert!(shared.handle.snapshot().sessions.is_empty());
20075 assert!(shared
20076 .session_bindings
20077 .lock()
20078 .unwrap_or_else(|poisoned| poisoned.into_inner())
20079 .is_empty());
20080 assert!(shared
20081 .materializations
20082 .lock()
20083 .unwrap_or_else(|poisoned| poisoned.into_inner())
20084 .is_empty());
20085 }
20086
20087 #[tokio::test]
20088 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20089 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20090 let error = characterization_spawn(
20091 &shared,
20092 SessionMode::Pty,
20093 None,
20094 &[ProviderRuntimeCapability::SemanticReadiness],
20095 None,
20096 )
20097 .await
20098 .unwrap_err();
20099 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20100 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20101 assert_no_session_state(&shared);
20102 drop(shared);
20103 drop(runtime);
20104 std::fs::remove_dir_all(root).unwrap();
20105 }
20106
20107 #[tokio::test]
20108 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20109 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20110 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20111 .await
20112 .unwrap_err();
20113 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20114 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20115 assert_no_session_state(&shared);
20116 drop(shared);
20117 drop(runtime);
20118 std::fs::remove_dir_all(root).unwrap();
20119 }
20120
20121 #[test]
20122 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20123 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20124 let address = SessionAddress {
20125 workspace_id: WorkspaceId::new("primary").unwrap(),
20126 session: SessionKey {
20127 instance_id: AgentInstanceId(77),
20128 generation: SessionGeneration(1),
20129 },
20130 };
20131 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20132 let removed = shared.remove_binding(&address).expect("binding removed");
20133 assert!(removed.environment_profile.is_some());
20134 assert!(shared.remove_binding(&address).is_none());
20135 assert_no_session_state(&shared);
20136 drop(shared);
20137 drop(runtime);
20138 std::fs::remove_dir_all(root).unwrap();
20139 }
20140
20141 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20142 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20143 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20144 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20145 .expect("review current test executable as fixture helper");
20146 let registry = HarnessMcpProxyRegistry::new(helper);
20147 let spec = crate::protocol::SpawnSpec {
20148 target: crate::protocol::SpawnTarget {
20149 node_id: NodeId::new("node-characterization").unwrap(),
20150 workspace_id: WorkspaceId::new("primary").unwrap(),
20151 worktree_id: None,
20152 },
20153 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20154 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20155 overrides: crate::protocol::SpawnOverrides::default(),
20156 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20157 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20158 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20159 };
20160 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20161 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20162 let launch = HarnessMcpLaunchV1 {
20163 server_name: "fixture-harness".to_owned(),
20164 args: vec!["--session-proxy".to_owned()],
20165 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20166 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20167 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20168 trace: None,
20169 scrub_env: Vec::new(),
20170 };
20171 let expires = unix_time_ms().saturating_add(30_000);
20172 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20173 registry
20174 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20175 .unwrap()
20176 }
20177
20178 #[tokio::test]
20179 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20180 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20181 let prepared = characterization_prepared_harness("claude").await;
20182 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20183 .await
20184 .unwrap_err();
20185 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20187 let mismatched = characterization_prepared_harness("codex").await;
20188 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20189 .await
20190 .unwrap_err();
20191 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20192 assert_no_session_state(&shared);
20193 drop(shared);
20194 drop(runtime);
20195 std::fs::remove_dir_all(root).unwrap();
20196 }
20197
20198 #[tokio::test]
20199 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20200 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20201 shared.native_launch_profile_control = None;
20202 let prepared = characterization_prepared_harness("claude").await;
20203 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20204 .await
20205 .unwrap_err();
20206 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20207 assert_no_session_state(&shared);
20208 drop(shared);
20209 drop(runtime);
20210 std::fs::remove_dir_all(root).unwrap();
20211 }
20212}
20213
20214#[cfg(all(test, windows))]
20215mod tests {
20216 use super::*;
20217 use gate4agent_types::ProviderActivity;
20218 use crate::session_environment::{
20219 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20220 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20221 NodeSessionPathClass,
20222 };
20223 use gate4agent_catalog::EnvMutation;
20224 use gate4agent_runtime_native::{
20225 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20226 NativeLaunchProfile, NativeLaunchProfileId,
20227 };
20228 use std::ffi::OsString;
20229 use std::sync::Arc;
20230
20231 struct EmptyEnvironmentResolver;
20232
20233 struct FixtureSecretResolver {
20234 deny: Arc<AtomicBool>,
20235 }
20236
20237 impl NodeSecretResolver for FixtureSecretResolver {
20238 fn resolve(
20239 &self,
20240 _reference: &NodeSecretReference,
20241 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20242 if self.deny.load(Ordering::Acquire) {
20243 Err(NodeSecretResolveError::Denied)
20244 } else {
20245 NodeSecretValue::text("fixture-secret")
20246 .map_err(|_| NodeSecretResolveError::Unavailable)
20247 }
20248 }
20249 }
20250
20251 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20252 fn resolve_child_environment(
20253 &self,
20254 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20255 Ok(vec![EnvMutation {
20256 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20257 value: None,
20258 }])
20259 }
20260 }
20261
20262 fn agent(value: &str) -> AgentId {
20263 AgentId::new(value).unwrap()
20264 }
20265
20266 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20267 let catalog = active_registry().unwrap();
20268 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20269 let workspace = WorkspaceConfig::new(
20270 WorkspaceId::new("primary").unwrap(),
20271 std::env::current_dir().unwrap(),
20272 )
20273 .unwrap()
20274 .with_worktree_service_mode(mode);
20275 NodeShared::new(
20276 handle,
20277 "fixture-token".to_owned(),
20278 NodeId::new("node-terminal-test").unwrap(),
20279 vec![workspace],
20280 vec![agent("claude")],
20281 )
20282 }
20283
20284 fn terminal_test_shared() -> NodeShared {
20285 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20286 }
20287
20288 #[cfg(feature = "fixture")]
20289 #[test]
20290 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20291 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20292 assert_eq!(probe.latest_code(), None);
20293
20294 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20295 assert_eq!(
20296 probe.latest_code(),
20297 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20298 );
20299
20300 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20301 assert_eq!(
20302 probe.latest_code(),
20303 Some(NodeFailureCode::SpawnDeadlineExceeded),
20304 );
20305
20306 probe.record(None);
20307 assert_eq!(probe.latest_code(), None);
20308 }
20309
20310 #[test]
20311 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20312 let source = temporary_workspace_root("launch-inventory-source");
20313 let allocation = temporary_workspace_root("launch-inventory-allocation");
20314 std::fs::create_dir_all(&source).unwrap();
20315 std::fs::create_dir_all(&allocation).unwrap();
20316 let workspace_id = WorkspaceId::new("primary").unwrap();
20317 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20318 .unwrap()
20319 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20320 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20321 workspace = workspace
20322 .with_managed_worktree_profile(
20323 ManagedWorktreeProfile::new(
20324 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20325 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20326 &allocation,
20327 "gate4agent",
20328 "HEAD",
20329 ManagedWorktreeRetention::RemoveWhenReleased,
20330 )
20331 .unwrap(),
20332 )
20333 .unwrap();
20334 }
20335 let overflow = workspace.clone().with_managed_worktree_profile(
20336 ManagedWorktreeProfile::new(
20337 WorktreeProfileId::new("overflow").unwrap(),
20338 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20339 &allocation,
20340 "gate4agent",
20341 "HEAD",
20342 ManagedWorktreeRetention::Retain,
20343 )
20344 .unwrap(),
20345 );
20346 assert!(matches!(
20347 overflow,
20348 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20349 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20350 ..
20351 }),
20352 ));
20353
20354 let catalog = active_registry().unwrap();
20355 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20356 let shared = NodeShared::new(
20357 handle,
20358 "fixture-token".to_owned(),
20359 NodeId::new("node-launch-inventory").unwrap(),
20360 vec![workspace],
20361 vec![agent("claude")],
20362 );
20363 let snapshot = shared.snapshot();
20364 let launch = snapshot.launch_inventory.as_ref().unwrap();
20365 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20366 assert!(launch.bundles.as_ref().unwrap().is_empty());
20367 let profiles = snapshot.workspaces[0]
20368 .managed_worktree_profiles
20369 .as_ref()
20370 .unwrap();
20371 assert_eq!(
20372 snapshot.workspaces[0].worktree_service_mode,
20373 Some(WorktreeServiceMode::Managed),
20374 );
20375 assert_eq!(
20376 profiles.profiles.len(),
20377 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20378 );
20379 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20380 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20381 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20382 }
20383 let legacy = snapshot_for_wire(
20384 &shared,
20385 true,
20386 true,
20387 false,
20388 false,
20389 true,
20390 false,
20391 true,
20392 false,
20393 true,
20394 );
20395 assert!(legacy.launch_inventory.is_none());
20396 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20397
20398 drop(shared);
20399 std::fs::remove_dir_all(source).unwrap();
20400 std::fs::remove_dir_all(allocation).unwrap();
20401 }
20402
20403 #[test]
20404 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20405 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20406 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20407 }));
20408 let source = gate4agent_types::ProviderSource {
20409 family: AdapterFamily::PtySemantic,
20410 binding: AdapterBinding::new(
20411 gate4agent_types::AdapterId::new("claude").unwrap(),
20412 "fixture/v1",
20413 gate4agent_types::AdapterVerification::SyntheticFixture,
20414 )
20415 .unwrap(),
20416 };
20417 let provider = ProviderSnapshot {
20418 sequence: 17,
20419 session: Some(ProviderSessionIdentity {
20420 key: ProviderSessionKey::SessionId,
20421 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20422 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20423 }),
20424 model: Some("PRIVATE_MODEL".to_owned()),
20425 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20426 completed_turns: 3,
20427 usage: gate4agent_types::TokenUsage {
20428 input_tokens: 10,
20429 output_tokens: 20,
20430 cache_read_tokens: 30,
20431 cache_write_tokens: 40,
20432 reasoning_tokens: 50,
20433 context_window: Some(200_000),
20434 },
20435 lead_activity: ProviderActivity::WaitingForInput,
20436 activity: ProviderActivity::WaitingForInput,
20437 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20438 active_tools: (0..9)
20439 .map(|index| gate4agent_types::ActiveProviderTool {
20440 id: format!("private-tool-id-{index}"),
20441 name: if index == 0 {
20442 format!(" Read\u{0000}{}", "x".repeat(80))
20443 } else {
20444 format!("Tool-{index}")
20445 },
20446 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20447 })
20448 .collect(),
20449 interactions: vec![gate4agent_types::ProviderInteraction {
20450 id: gate4agent_types::ProviderInteractionId(17),
20451 source: source.clone(),
20452 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20453 interaction_kind: ProviderInteractionKind::Approval,
20454 tool_name: " Approve\u{0007}Tool ".to_owned(),
20455 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20456 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20457 resume_lead_activity: Some(ProviderActivity::Working),
20458 status: ProviderInteractionStatus::Pending,
20459 }],
20460 subagents: vec![gate4agent_types::ProviderSubagent {
20461 source: source.clone(),
20462 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20463 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20464 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20465 }],
20466 sources: vec![gate4agent_types::ProviderSourceCursor {
20467 source,
20468 sequence: 17,
20469 gap_count: 2,
20470 stale: true,
20471 }],
20472 last_event: Some(ProviderEvent::Error {
20473 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20474 }),
20475 gap_count: 2,
20476 stale: true,
20477 };
20478 let address = terminal_address(3);
20479 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20480 assert_eq!(entry.address, address);
20481 assert_eq!(entry.progress.provider_sequence, 17);
20482 assert_eq!(entry.progress.active_tool_count, 9);
20483 assert_eq!(
20484 entry.progress.active_tool_labels.len(),
20485 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20486 );
20487 assert_eq!(entry.progress.subagent_count, 1);
20488 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20489 assert!(entry.progress.truncated);
20490 let encoded = serde_json::to_string(&entry).unwrap();
20491 for forbidden in [
20492 "PRIVATE_PROVIDER_SESSION",
20493 "PRIVATE_PROVIDER_PATH",
20494 "PRIVATE_MODEL",
20495 "PRIVATE_TOOL_INVENTORY",
20496 "PRIVATE_CURRENT_PROMPT",
20497 "PRIVATE_TOOL_INPUT_JSON",
20498 "PRIVATE_PROVIDER_REQUEST",
20499 "PRIVATE_INTERACTION_PROMPT",
20500 "PRIVATE_INTERACTION_AGENT",
20501 "PRIVATE_SUBAGENT_ID",
20502 "PRIVATE_SUBAGENT_TYPE",
20503 "PRIVATE_SUBAGENT_DESCRIPTION",
20504 "PRIVATE_PROVIDER_ERROR",
20505 "input_json",
20506 "current_prompt",
20507 "session_id",
20508 ] {
20509 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20510 }
20511
20512 let snapshot = NodeSnapshot {
20513 node_id: NodeId::new("node-progress-test").unwrap(),
20514 enabled_providers: vec![agent("claude")],
20515 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20516 workspaces: Vec::new(),
20517 session_records: Vec::new(),
20518 managed_worktrees: Vec::new(),
20519 launch_inventory: None,
20520 agent_progress: vec![entry],
20521 };
20522 let mut legacy_reply = ResponseEnvelope {
20523 request_id: 1,
20524 result: Ok(NodeResponse::Snapshot {
20525 event_sequence: 0,
20526 controller: None,
20527 snapshot: snapshot.clone(),
20528 }),
20529 };
20530 project_response_without_agent_progress(&mut legacy_reply);
20531 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20532 panic!("agent progress projection changed response kind");
20533 };
20534 assert!(legacy.agent_progress.is_empty());
20535 assert_eq!(snapshot.agent_progress.len(), 1);
20536 }
20537
20538 fn terminal_address(generation: u64) -> SessionAddress {
20539 SessionAddress {
20540 workspace_id: WorkspaceId::new("primary").unwrap(),
20541 session: SessionKey {
20542 instance_id: AgentInstanceId(41),
20543 generation: SessionGeneration(generation),
20544 },
20545 }
20546 }
20547
20548 fn spawn_spec_fixture() -> SpawnSpec {
20549 SpawnSpec {
20550 target: crate::protocol::SpawnTarget {
20551 node_id: NodeId::new("node-terminal-test").unwrap(),
20552 workspace_id: WorkspaceId::new("primary").unwrap(),
20553 worktree_id: None,
20554 },
20555 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20556 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20557 .unwrap(),
20558 overrides: crate::protocol::SpawnOverrides::default(),
20559 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20560 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20561 required_capabilities: SpawnRequiredCapabilities::default(),
20562 }
20563 }
20564
20565 fn environment_profile_test_shared(
20566 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20567 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20568 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20569 "local-claude-r1",
20570 )
20571 .unwrap();
20572 let spawn_profiles = SpawnProfileRegistry::new([
20573 crate::protocol::SpawnProfileDefaults {
20574 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20575 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20576 provider: agent("claude"),
20577 mode: SessionMode::Pty,
20578 terminal_size: gate4agent_types::TerminalSize {
20579 rows: 24,
20580 columns: 80,
20581 },
20582 prompt: None,
20583 bundle_id: None,
20584 context_id: None,
20585 environment_profile_id: Some(profile_id.clone()),
20586 },
20587 ])
20588 .unwrap();
20589 let catalog = active_registry().unwrap();
20590 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20591 let control = runtime.native_launch_profile_control();
20592 let node_profile = NodeEnvironmentProfile::new(
20593 profile_id.clone(),
20594 profile_revision.clone(),
20595 agent("claude"),
20596 [NativeLaunchProfile::new(
20597 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20598 agent("claude"),
20599 TransportKind::Pty,
20600 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20601 Arc::new(EmptyEnvironmentResolver),
20602 )
20603 .unwrap()],
20604 )
20605 .unwrap();
20606 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20607 for native_profile in native_profiles {
20608 runtime.upsert_native_launch_profile(native_profile).unwrap();
20609 }
20610 let workspace = WorkspaceConfig::new(
20611 WorkspaceId::new("primary").unwrap(),
20612 std::env::current_dir().unwrap(),
20613 )
20614 .unwrap();
20615 let shared = NodeShared::new_with_incarnation(
20616 handle,
20617 "fixture-token".to_owned(),
20618 NodeId::new("node-terminal-test").unwrap(),
20619 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20620 vec![workspace],
20621 vec![agent("claude")],
20622 ProviderRuntimeStatuses::default(),
20623 None,
20624 Vec::new(),
20625 Vec::new(),
20626 spawn_profiles,
20627 Some(control),
20628 None,
20629 None,
20630 None,
20631 Vec::new(),
20632 Vec::new(),
20633 Vec::new(),
20634 Vec::new(),
20635 None,
20636 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20637 );
20638 shared
20639 .environment_profiles
20640 .write()
20641 .unwrap_or_else(|poisoned| poisoned.into_inner())
20642 .insert(binding.id.clone(), binding);
20643 (
20644 shared,
20645 runtime,
20646 ResolvedEnvironmentProfileReceipt {
20647 profile_id,
20648 profile_revision,
20649 network_allowlist: None,
20650 browser_profile_id: None,
20651 },
20652 )
20653 }
20654
20655 fn materialization_test_shared(
20656 deny_secret: bool,
20657 ) -> (
20658 NodeShared,
20659 NativeRuntime,
20660 ResolvedEnvironmentProfileReceipt,
20661 PathBuf,
20662 Arc<AtomicBool>,
20663 ) {
20664 let workspace = WorkspaceConfig::new(
20665 WorkspaceId::new("primary").unwrap(),
20666 std::env::current_dir().unwrap(),
20667 )
20668 .unwrap();
20669 materialization_test_shared_with_workspace(deny_secret, workspace)
20670 }
20671
20672 fn materialization_test_shared_with_workspace(
20673 deny_secret: bool,
20674 workspace: WorkspaceConfig,
20675 ) -> (
20676 NodeShared,
20677 NativeRuntime,
20678 ResolvedEnvironmentProfileReceipt,
20679 PathBuf,
20680 Arc<AtomicBool>,
20681 ) {
20682 let root = temporary_workspace_root(if deny_secret {
20683 "materialization-denied"
20684 } else {
20685 "materialization-ready"
20686 });
20687 std::fs::create_dir_all(&root).unwrap();
20688 let materialization_root = root.join("session-environments");
20689 let deny = Arc::new(AtomicBool::new(deny_secret));
20690 let resolver = Arc::new(FixtureSecretResolver {
20691 deny: Arc::clone(&deny),
20692 });
20693 let materializer = SessionEnvironmentMaterializer::new(
20694 materialization_root,
20695 resolver,
20696 )
20697 .unwrap();
20698 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20699 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20700 "materialized-claude-r1",
20701 )
20702 .unwrap();
20703 let materialization = NodeSessionMaterializationProfile::new(
20704 vec![NodeSessionEnvironmentMutation::SetSecret {
20705 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20706 reference: NodeSecretReference::new("fixture-token").unwrap(),
20707 }],
20708 vec![NodeSessionPathBinding::new(
20709 "GATE4AGENT_PROVIDER_HOME",
20710 NodeSessionPathClass::ProviderHome,
20711 )
20712 .unwrap()],
20713 vec![NodeSessionFile::secret(
20714 NodeSessionPathClass::Config,
20715 "auth/token",
20716 NodeSecretReference::new("fixture-token").unwrap(),
20717 )
20718 .unwrap()],
20719 )
20720 .unwrap();
20721 let catalog = active_registry().unwrap();
20722 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20723 let control = runtime.native_launch_profile_control();
20724 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20725 profile_id.clone(),
20726 profile_revision.clone(),
20727 agent("claude"),
20728 [NativeLaunchProfile::new(
20729 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20730 agent("claude"),
20731 TransportKind::Pty,
20732 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20733 Arc::new(EmptyEnvironmentResolver),
20734 )
20735 .unwrap()],
20736 Some(materialization),
20737 )
20738 .unwrap();
20739 let (binding, native_profiles, materialization) = node_profile.into_parts();
20740 for native_profile in native_profiles {
20741 runtime.upsert_native_launch_profile(native_profile).unwrap();
20742 }
20743 let shared = NodeShared::new_with_incarnation(
20744 handle,
20745 "fixture-token".to_owned(),
20746 NodeId::new("node-materialization-test").unwrap(),
20747 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20748 vec![workspace],
20749 vec![agent("claude")],
20750 ProviderRuntimeStatuses::default(),
20751 None,
20752 Vec::new(),
20753 Vec::new(),
20754 SpawnProfileRegistry::default(),
20755 Some(control),
20756 None,
20757 Some(materializer),
20758 None,
20759 Vec::new(),
20760 Vec::new(),
20761 Vec::new(),
20762 Vec::new(),
20763 None,
20764 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20765 );
20766 shared
20767 .environment_profiles
20768 .write()
20769 .unwrap_or_else(|poisoned| poisoned.into_inner())
20770 .insert(binding.id.clone(), binding);
20771 shared
20772 .environment_materialization_profiles
20773 .write()
20774 .unwrap_or_else(|poisoned| poisoned.into_inner())
20775 .insert(profile_id.clone(), materialization.unwrap());
20776 (
20777 shared,
20778 runtime,
20779 ResolvedEnvironmentProfileReceipt {
20780 profile_id,
20781 profile_revision,
20782 network_allowlist: None,
20783 browser_profile_id: None,
20784 },
20785 root,
20786 deny,
20787 )
20788 }
20789
20790 #[cfg(feature = "fixture")]
20791 #[derive(Clone, Copy)]
20792 enum CodexHomeProfile {
20793 Missing,
20794 WrongClass,
20795 Exact,
20796 }
20797
20798 #[cfg(feature = "fixture")]
20799 fn codex_bundle_test_shared(
20800 home_profile: CodexHomeProfile,
20801 ) -> (
20802 NodeShared,
20803 NativeRuntime,
20804 ResolvedEnvironmentProfileReceipt,
20805 NodeBundle,
20806 PathBuf,
20807 ) {
20808 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20809 const CLAUDE_MANIFEST: &[u8] =
20810 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20811 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20812 const DIGEST: &str =
20813 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20814
20815 let (shared, mut runtime, _claude_receipt, root, _deny) =
20816 materialization_test_shared(false);
20817 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20818 CodexHomeProfile::Missing => "codex-home-missing",
20819 CodexHomeProfile::WrongClass => "codex-home-wrong",
20820 CodexHomeProfile::Exact => "codex-home-exact",
20821 })
20822 .unwrap();
20823 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20824 "codex-home-r1",
20825 )
20826 .unwrap();
20827 let materialization = match home_profile {
20828 CodexHomeProfile::Missing => None,
20829 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20830 Some(NodeSessionMaterializationProfile::new(
20831 Vec::new(),
20832 vec![NodeSessionPathBinding::new(
20833 "CODEX_HOME",
20834 if matches!(home_profile, CodexHomeProfile::Exact) {
20835 NodeSessionPathClass::ProviderHome
20836 } else {
20837 NodeSessionPathClass::Config
20838 },
20839 )
20840 .unwrap()],
20841 Vec::new(),
20842 )
20843 .unwrap())
20844 }
20845 };
20846 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20847 profile_id.clone(),
20848 profile_revision.clone(),
20849 agent("codex"),
20850 [NativeLaunchProfile::new(
20851 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20852 agent("codex"),
20853 TransportKind::Pty,
20854 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20855 Arc::new(EmptyEnvironmentResolver),
20856 )
20857 .unwrap()],
20858 materialization,
20859 )
20860 .unwrap();
20861 let (binding, native_profiles, materialization) = node_profile.into_parts();
20862 for native_profile in native_profiles {
20863 runtime.upsert_native_launch_profile(native_profile).unwrap();
20864 }
20865 shared
20866 .environment_profiles
20867 .write()
20868 .unwrap_or_else(|poisoned| poisoned.into_inner())
20869 .insert(binding.id.clone(), binding);
20870 if let Some(materialization) = materialization {
20871 shared
20872 .environment_materialization_profiles
20873 .write()
20874 .unwrap_or_else(|poisoned| poisoned.into_inner())
20875 .insert(profile_id.clone(), materialization);
20876 }
20877
20878 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20879 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20880 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20881 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20882 std::fs::write(
20883 bundle_source.join(".claude-plugin/plugin.json"),
20884 CLAUDE_MANIFEST,
20885 )
20886 .unwrap();
20887 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20888 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20889 let bundle = NodeBundle::new(
20890 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20891 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20892 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20893 &bundle_source,
20894 )
20895 .unwrap();
20896 *shared
20897 .bundle_catalog
20898 .write()
20899 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20900 BundleCatalog::new([bundle.clone()]).unwrap();
20901 (
20902 shared,
20903 runtime,
20904 ResolvedEnvironmentProfileReceipt {
20905 profile_id,
20906 profile_revision,
20907 network_allowlist: None,
20908 browser_profile_id: None,
20909 },
20910 bundle,
20911 root,
20912 )
20913 }
20914
20915 #[cfg(feature = "fixture")]
20916 #[test]
20917 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20918 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20919 let (shared, runtime, receipt, bundle, root) =
20920 codex_bundle_test_shared(home_profile);
20921 let error = shared
20922 .bundle_layout(
20923 &agent("codex"),
20924 SessionMode::Pty,
20925 Some(&receipt),
20926 &bundle,
20927 )
20928 .unwrap_err();
20929 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20930 assert!(shared
20931 .materializations
20932 .lock()
20933 .unwrap_or_else(|poisoned| poisoned.into_inner())
20934 .is_empty());
20935 let materialization_root = root.join("session-environments");
20936 assert!(!std::fs::read_dir(&materialization_root)
20937 .unwrap()
20938 .filter_map(Result::ok)
20939 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20940 drop(shared);
20941 drop(runtime);
20942 let _ = std::fs::remove_dir_all(root);
20943 }
20944 }
20945
20946 #[cfg(feature = "fixture")]
20947 #[test]
20948 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20949 let (shared, runtime, receipt, bundle, root) =
20950 codex_bundle_test_shared(CodexHomeProfile::Exact);
20951 let address = SessionAddress {
20952 workspace_id: WorkspaceId::new("primary").unwrap(),
20953 session: SessionKey {
20954 instance_id: AgentInstanceId(310),
20955 generation: SessionGeneration(1),
20956 },
20957 };
20958 let (overlay, guard) = shared
20959 .prepare_session_materialization(
20960 &address,
20961 &agent("codex"),
20962 SessionMode::Pty,
20963 Some(&receipt),
20964 Some(&bundle.receipt()),
20965 None,
20966 None,
20967 )
20968 .unwrap()
20969 .unwrap();
20970 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20971 let ownership = shared
20972 .materializations
20973 .lock()
20974 .unwrap_or_else(|poisoned| poisoned.into_inner())
20975 .get(guard.id().unwrap())
20976 .unwrap()
20977 .clone();
20978 let (profile, _, _) = shared
20979 .materialization_profile(
20980 &agent("codex"),
20981 SessionMode::Pty,
20982 Some(&receipt),
20983 Some(&bundle.receipt()),
20984 None,
20985 )
20986 .unwrap()
20987 .unwrap();
20988 let environment = shared
20989 .session_environment_materializer
20990 .as_ref()
20991 .unwrap()
20992 .resolve_environment(&ownership, &profile)
20993 .unwrap();
20994 assert!(environment.iter().any(|mutation| {
20995 mutation.key == OsString::from("CODEX_HOME")
20996 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20997 }));
20998 assert_eq!(
20999 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21000 .unwrap(),
21001 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21002 );
21003 assert!(std::fs::read_dir(ownership.bundle_root())
21004 .unwrap()
21005 .next()
21006 .is_none());
21007 drop(guard);
21008 drop(shared);
21009 drop(runtime);
21010 let _ = std::fs::remove_dir_all(root);
21011 }
21012
21013 #[cfg(feature = "fixture")]
21014 #[test]
21015 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21016 let (shared, runtime, receipt, bundle, root) =
21017 codex_bundle_test_shared(CodexHomeProfile::Exact);
21018 let bundle_receipt = bundle.receipt();
21019 let address = SessionAddress {
21020 workspace_id: WorkspaceId::new("primary").unwrap(),
21021 session: SessionKey {
21022 instance_id: AgentInstanceId(311),
21023 generation: SessionGeneration(1),
21024 },
21025 };
21026 let (_overlay, guard) = shared
21027 .prepare_session_materialization(
21028 &address,
21029 &agent("codex"),
21030 SessionMode::Pty,
21031 Some(&receipt),
21032 Some(&bundle_receipt),
21033 None,
21034 None,
21035 )
21036 .unwrap()
21037 .unwrap();
21038 let materialization_id = guard.id().unwrap().clone();
21039 let skill_path = shared
21040 .materializations
21041 .lock()
21042 .unwrap_or_else(|poisoned| poisoned.into_inner())
21043 .get(&materialization_id)
21044 .unwrap()
21045 .provider_home()
21046 .join("skills/review-code/SKILL.md");
21047 let record_id = shared
21048 .bind_spawn_session_with_materialization(
21049 &address,
21050 agent("codex"),
21051 SessionMode::Pty,
21052 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21053 SpawnRecordPolicy::ProviderIdentityOnly,
21054 Some(receipt.clone()),
21055 Some(bundle_receipt.clone()),
21056 None,
21057 Some(materialization_id.clone()),
21058 )
21059 .unwrap()
21060 .unwrap();
21061 guard.retain();
21062 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21063
21064 let error = match shared.resolve_record_materialization(
21065 &record_id,
21066 &agent("codex"),
21067 SessionMode::Pty,
21068 Some(&receipt),
21069 Some(&bundle_receipt),
21070 None,
21071 ) {
21072 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21073 Err(error) => error,
21074 };
21075 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21076 assert_eq!(
21077 shared
21078 .materializations
21079 .lock()
21080 .unwrap_or_else(|poisoned| poisoned.into_inner())
21081 .get(&materialization_id)
21082 .unwrap()
21083 .state(),
21084 MaterializationState::RecoveryRequired,
21085 );
21086 drop(shared);
21087 drop(runtime);
21088 let _ = std::fs::remove_dir_all(root);
21089 }
21090
21091 fn bind_record_owned_materialization_fixture(
21092 shared: &NodeShared,
21093 receipt: &ResolvedEnvironmentProfileReceipt,
21094 instance_id: u64,
21095 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21096 let address = SessionAddress {
21097 workspace_id: WorkspaceId::new("primary").unwrap(),
21098 session: SessionKey {
21099 instance_id: AgentInstanceId(instance_id),
21100 generation: SessionGeneration(1),
21101 },
21102 };
21103 let (overlay, materialization_guard) = shared
21104 .prepare_session_materialization(
21105 &address,
21106 &agent("claude"),
21107 SessionMode::Pty,
21108 Some(receipt),
21109 None,
21110 None,
21111 None,
21112 )
21113 .unwrap()
21114 .unwrap();
21115 let materialization_id = materialization_guard.id().unwrap().clone();
21116 let materialization_root = shared
21117 .materializations
21118 .lock()
21119 .unwrap_or_else(|poisoned| poisoned.into_inner())
21120 .get(&materialization_id)
21121 .unwrap()
21122 .root()
21123 .to_path_buf();
21124 let selection = shared
21125 .select_environment_profile(
21126 address.session.instance_id,
21127 &agent("claude"),
21128 SessionMode::Pty,
21129 Some(receipt),
21130 )
21131 .unwrap()
21132 .unwrap();
21133 assert!(shared
21134 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21135 .unwrap()
21136 .is_none());
21137 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21138 let record_id = shared
21139 .bind_spawn_session_with_materialization(
21140 &address,
21141 agent("claude"),
21142 SessionMode::Pty,
21143 policy,
21144 SpawnRecordPolicy::ProviderIdentityOnly,
21145 Some(receipt.clone()),
21146 None,
21147 None,
21148 Some(materialization_id.clone()),
21149 )
21150 .unwrap()
21151 .unwrap();
21152 selection.retain();
21153 materialization_guard.retain();
21154 (address, record_id, materialization_id, materialization_root)
21155 }
21156
21157 #[test]
21158 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21159 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21160 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21161 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21162 }));
21163 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21164 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21165 let mut spec = spawn_spec_fixture();
21166 spec.expected_profile_revision =
21167 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21168 assert!(matches!(
21169 &spec.overrides.environment_profile_id,
21170 crate::protocol::SpawnOverride::Inherit,
21171 ));
21172 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21173 .requires_child_environment_profile_capability());
21174 assert!(request_requires_child_environment_profile(
21175 &shared,
21176 &NodeRequest::SpawnSpec { spec: spec.clone() },
21177 ));
21178
21179 let address = terminal_address(1);
21180 shared.bind_session_with_policy(
21181 &address,
21182 ProviderRuntimePolicy::raw_pty(),
21183 Some(environment_profile.clone()),
21184 );
21185 assert!(request_requires_child_environment_profile(
21186 &shared,
21187 &NodeRequest::Input {
21188 session: address.clone(),
21189 text: "x".to_owned(),
21190 },
21191 ));
21192
21193 let mut record = record("claude", "profile-record");
21194 record.active_session = Some(address.clone());
21195 record.environment_profile = Some(environment_profile.clone());
21196 let record_id = record.record_id.clone();
21197 shared.insert_record(record.clone()).unwrap();
21198 assert!(request_requires_child_environment_profile(
21199 &shared,
21200 &NodeRequest::ResumeSessionRecord {
21201 record_id,
21202 terminal_size: gate4agent_types::TerminalSize {
21203 rows: 24,
21204 columns: 80,
21205 },
21206 initial_prompt: None,
21207 },
21208 ));
21209
21210 let snapshot = snapshot_for_wire(
21211 &shared, false, true, true, true, false, false, false, false, true,
21212 );
21213 assert_eq!(snapshot.session_records.len(), 1);
21214 assert!(snapshot.session_records[0].environment_profile.is_none());
21215 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21216 sequence: 1,
21217 event: NodeEvent::SessionRecordUpserted { record },
21218 });
21219 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21220 panic!("record projection changed the event kind");
21221 };
21222 assert!(record.environment_profile.is_none());
21223
21224 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21225 let mut reply = ResponseEnvelope {
21226 request_id: 1,
21227 result: Ok(NodeResponse::SpawnSpecAccepted {
21228 receipt: resolved.receipt_with_environment(
21229 shared.incarnation_id,
21230 address,
21231 Some(environment_profile),
21232 ),
21233 }),
21234 };
21235 project_response_without_child_environment_profile(&mut reply);
21236 assert_eq!(
21237 reply.result.unwrap_err().code,
21238 NodeFailureCode::UnsupportedCapability,
21239 );
21240 }
21241
21242 #[test]
21243 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21244 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21245 let snapshot = shared.snapshot();
21246 let profiles = snapshot
21247 .launch_inventory
21248 .as_ref()
21249 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21250 .unwrap();
21251 assert_eq!(profiles.len(), 1);
21252 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21253 assert!(snapshot.requires_child_environment_profile_capability());
21254
21255 let mut legacy = snapshot.clone();
21256 clear_snapshot_child_environment_profiles(&mut legacy);
21257 assert!(legacy
21258 .launch_inventory
21259 .as_ref()
21260 .unwrap()
21261 .spawn_profiles
21262 .as_ref()
21263 .unwrap()[0]
21264 .environment_profile
21265 .is_none());
21266 assert!(!legacy.requires_child_environment_profile_capability());
21267
21268 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21269 let bindings = shared
21270 .environment_profiles
21271 .read()
21272 .unwrap_or_else(|poisoned| poisoned.into_inner())
21273 .clone();
21274 let mut provider_mismatch = bindings.clone();
21275 provider_mismatch
21276 .values_mut()
21277 .next()
21278 .unwrap()
21279 .provider = agent("codex");
21280 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21281 let mut mode_mismatch = profile;
21282 mode_mismatch.mode = SessionMode::Inline;
21283 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21284
21285 shared
21286 .environment_profiles
21287 .write()
21288 .unwrap_or_else(|poisoned| poisoned.into_inner())
21289 .clear();
21290 assert!(shared
21291 .snapshot()
21292 .launch_inventory
21293 .unwrap()
21294 .spawn_profiles
21295 .unwrap()
21296 .is_empty());
21297 }
21298
21299 #[test]
21300 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21301 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21302 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21303 }));
21304 let shared = terminal_test_shared();
21305 let bundle = ResolvedBundleReceipt {
21306 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21307 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21308 digest: crate::protocol::SpawnBundleDigest::new(format!(
21309 "sha256:{}",
21310 "0".repeat(64),
21311 ))
21312 .unwrap(),
21313 };
21314 let mut bundled = record("claude", "bundle-record");
21315 bundled.bundle = Some(bundle.clone());
21316 let record_id = bundled.record_id.clone();
21317 shared.insert_record(bundled.clone()).unwrap();
21318 assert!(request_requires_session_bundle(
21319 &shared,
21320 &NodeRequest::ForgetSessionRecord {
21321 record_id: record_id.clone(),
21322 },
21323 ));
21324
21325 let snapshot = snapshot_for_wire(
21326 &shared, false, true, true, true, true, false, false, false, true,
21327 );
21328 assert!(snapshot.session_records[0].bundle.is_none());
21329 let launch = snapshot.launch_inventory.as_ref().unwrap();
21330 assert!(launch.spawn_profiles.is_some());
21331 assert!(launch.bundles.is_none());
21332 let event = project_event_without_session_bundle(NodeEventEnvelope {
21333 sequence: 1,
21334 event: NodeEvent::SessionRecordUpserted { record: bundled },
21335 });
21336 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21337 panic!("record projection changed the event kind");
21338 };
21339 assert!(record.bundle.is_none());
21340
21341 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21342 let mut reply = ResponseEnvelope {
21343 request_id: 1,
21344 result: Ok(NodeResponse::SpawnSpecAccepted {
21345 receipt: resolved.receipt_with_materialization(
21346 shared.incarnation_id,
21347 terminal_address(17),
21348 None,
21349 Some(bundle),
21350 None,
21351 ),
21352 }),
21353 };
21354 project_response_without_session_bundle(&mut reply);
21355 assert_eq!(
21356 reply.result.unwrap_err().code,
21357 NodeFailureCode::UnsupportedCapability,
21358 );
21359 }
21360
21361 #[test]
21362 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21363 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21364 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21365 }));
21366 let context_id = SpawnContextId::new("context-review").unwrap();
21367 let context = ResolvedContextPackReceipt {
21368 id: context_id.clone(),
21369 digest: crate::protocol::SpawnContextDigest::new(format!(
21370 "sha256:{}",
21371 "c".repeat(64),
21372 ))
21373 .unwrap(),
21374 lineage: ContextPackLineageReceipt {
21375 source_node_id: NodeId::new("node-source").unwrap(),
21376 source_session: terminal_address(1),
21377 source_provider: agent("kimi"),
21378 },
21379 source_message_count: 2,
21380 retained_message_count: 2,
21381 byte_len: 256,
21382 truncated: false,
21383 };
21384 assert!(context.is_valid());
21385 let mut bound_record = record("codex", "context-record");
21386 bound_record.context_id = Some(context_id);
21387 bound_record.context = Some(context.clone());
21388 let session = gate4agent_types::SessionSnapshot {
21389 instance_id: AgentInstanceId(91),
21390 agent_id: agent("kimi"),
21391 transport: TransportKind::Pty,
21392 generation: SessionGeneration(3),
21393 status: SessionStatus::Running,
21394 pending_operation: None,
21395 pending_input: None,
21396 process_id: Some(9001),
21397 terminal_size: None,
21398 terminal_frame: None,
21399 terminal_stale: None,
21400 session_options: None,
21401 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21402 history: gate4agent_types::HistorySnapshot {
21403 pending: None,
21404 candidates: vec![HistoryCandidateSummary {
21405 id: "candidate-opaque".to_owned(),
21406 session_id_hint: "session-hint".to_owned(),
21407 modified_at_unix_ms: Some(1),
21408 }],
21409 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21410 loaded: Some(HistorySessionRecord {
21411 session_id: "private-vendor-session".to_owned(),
21412 title: Some("private title".to_owned()),
21413 cwd: Some(r"C:\private\history-root".to_owned()),
21414 model: Some("private-model".to_owned()),
21415 message_count: 2,
21416 completed_turn_count: None,
21417 total_tokens: 19,
21418 messages: vec![
21419 gate4agent_types::HistoryMessageRecord {
21420 role: gate4agent_types::HistoryMessageRole::User,
21421 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21422 },
21423 gate4agent_types::HistoryMessageRecord {
21424 role: gate4agent_types::HistoryMessageRole::Assistant,
21425 text: "private answer".to_owned(),
21426 },
21427 ],
21428 }),
21429 last_error: None,
21430 },
21431 resume: gate4agent_types::ResumeSnapshot::default(),
21432 foreground: gate4agent_types::ForegroundSnapshot::default(),
21433 provider: gate4agent_types::ProviderSnapshot::default(),
21434 screen_state: gate4agent_types::PtyScreenState::default(),
21435 };
21436 let original = NodeSnapshot {
21437 node_id: NodeId::new("node-terminal-test").unwrap(),
21438 enabled_providers: vec![agent("kimi"), agent("codex")],
21439 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21440 workspaces: vec![WorkspaceSnapshot {
21441 workspace_id: WorkspaceId::new("primary").unwrap(),
21442 canonical_root: opaque_windows_path(
21443 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21444 ),
21445 sessions: vec![session],
21446 worktree_service_mode: None,
21447 managed_worktree_profiles: None,
21448 }],
21449 session_records: vec![bound_record],
21450 managed_worktrees: Vec::new(),
21451 launch_inventory: None,
21452 agent_progress: Vec::new(),
21453 };
21454
21455 let mut negotiated = original.clone();
21456 project_snapshot_history_for_wire(&mut negotiated, true);
21457 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21458 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21459 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21460 assert!(!serde_json::to_string(&negotiated)
21461 .unwrap()
21462 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21463
21464 let mut legacy = original;
21465 project_snapshot_history_for_wire(&mut legacy, false);
21466 clear_snapshot_context_packs(&mut legacy);
21467 assert_eq!(
21468 legacy.workspaces[0].sessions[0].history,
21469 gate4agent_types::HistorySnapshot::default(),
21470 );
21471 assert!(legacy.session_records[0].context_id.is_none());
21472 assert!(legacy.session_records[0].context.is_none());
21473 let encoded = serde_json::to_string(&legacy).unwrap();
21474 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21475 assert!(!encoded.contains("context-review"));
21476
21477 let mut reply = ResponseEnvelope {
21478 request_id: 7,
21479 result: Ok(NodeResponse::ContextPackExported { context }),
21480 };
21481 project_response_without_context_pack(&mut reply);
21482 assert_eq!(
21483 reply.result.unwrap_err().code,
21484 NodeFailureCode::UnsupportedCapability,
21485 );
21486 }
21487
21488 #[test]
21489 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21490 let shared = terminal_test_shared();
21491 let history = HistorySessionRecord {
21492 session_id: "fixture-history".to_owned(),
21493 title: None,
21494 cwd: None,
21495 model: None,
21496 message_count: 1,
21497 completed_turn_count: None,
21498 total_tokens: 0,
21499 messages: vec![gate4agent_types::HistoryMessageRecord {
21500 role: gate4agent_types::HistoryMessageRole::User,
21501 text: "bounded handoff".to_owned(),
21502 }],
21503 };
21504 let pack = NodeContextPack::export(
21505 ContextPackLineageReceipt {
21506 source_node_id: shared.node_id.clone(),
21507 source_session: terminal_address(1),
21508 source_provider: agent("claude"),
21509 },
21510 &history,
21511 )
21512 .unwrap();
21513 let context = shared
21514 .context_catalog
21515 .write()
21516 .unwrap_or_else(|poisoned| poisoned.into_inner())
21517 .insert(pack)
21518 .unwrap();
21519 let mut retained = record("codex", "context-retained-record");
21520 retained.context_id = Some(context.id.clone());
21521 retained.context = Some(context.clone());
21522 let record_id = retained.record_id.clone();
21523 shared.insert_record(retained).unwrap();
21524 assert_eq!(
21525 shared.forget_context_pack(&context.id).unwrap_err().code,
21526 NodeFailureCode::ContextPackBusy,
21527 );
21528 assert!(shared
21529 .context_catalog
21530 .read()
21531 .unwrap_or_else(|poisoned| poisoned.into_inner())
21532 .get(&context.id)
21533 .is_some());
21534 shared
21535 .session_records
21536 .lock()
21537 .unwrap_or_else(|poisoned| poisoned.into_inner())
21538 .records
21539 .remove(&record_id);
21540
21541 let mut spec = spawn_spec_fixture();
21542 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21543 value: context.id.clone(),
21544 };
21545 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21546 let receipt = resolved.receipt_with_materialization(
21547 shared.incarnation_id,
21548 terminal_address(2),
21549 None,
21550 None,
21551 Some(context.clone()),
21552 );
21553 assert!(receipt.context_binding_is_valid());
21554 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21555
21556 shared.forget_context_pack(&context.id).unwrap();
21557 assert!(shared
21558 .context_catalog
21559 .read()
21560 .unwrap_or_else(|poisoned| poisoned.into_inner())
21561 .get(&context.id)
21562 .is_none());
21563 assert!(shared
21564 .replay_spawn_spec(&spec, Instant::now())
21565 .unwrap()
21566 .is_none());
21567 }
21568
21569 #[test]
21570 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21571 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21572 let address = terminal_address(1);
21573 let selection = shared
21574 .select_environment_profile(
21575 address.session.instance_id,
21576 &agent("claude"),
21577 SessionMode::Pty,
21578 Some(&environment_profile),
21579 )
21580 .unwrap()
21581 .unwrap();
21582 shared.bind_session_with_policy(
21583 &address,
21584 ProviderRuntimePolicy::raw_pty(),
21585 Some(environment_profile.clone()),
21586 );
21587 selection.retain();
21588 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21589 assert!(matches!(
21590 runtime.remove_native_launch_profile(&native_id),
21591 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21592 ));
21593 assert!(shared.remove_binding(&address).is_some());
21594 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21595
21596 let mismatched = ResolvedEnvironmentProfileReceipt {
21597 profile_id: environment_profile.profile_id,
21598 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21599 "local-claude-r2",
21600 )
21601 .unwrap(),
21602 network_allowlist: None,
21603 browser_profile_id: None,
21604 };
21605 let error = match shared.select_environment_profile(
21606 AgentInstanceId(42),
21607 &agent("claude"),
21608 SessionMode::Pty,
21609 Some(&mismatched),
21610 ) {
21611 Ok(_) => panic!("mismatched environment profile revision was selected"),
21612 Err(error) => error,
21613 };
21614 assert_eq!(
21615 error.code,
21616 NodeFailureCode::EnvironmentProfileBindingMismatch,
21617 );
21618 assert!(!shared
21619 .session_bindings
21620 .lock()
21621 .unwrap_or_else(|poisoned| poisoned.into_inner())
21622 .contains_key(&AgentInstanceId(42)));
21623 }
21624
21625 #[test]
21626 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21627 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21628 let address = terminal_address(1);
21629 let selection = shared
21630 .select_environment_profile(
21631 address.session.instance_id,
21632 &agent("claude"),
21633 SessionMode::Pty,
21634 Some(&environment_profile),
21635 )
21636 .unwrap()
21637 .unwrap();
21638 let overlay = NativeInstanceLaunchOverlay::new(
21639 agent("claude"),
21640 TransportKind::Pty,
21641 vec![EnvMutation {
21642 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21643 value: Some(OsString::from("fixture-secret")),
21644 }],
21645 vec![
21646 OsString::from("--plugin-dir"),
21647 std::env::current_dir().unwrap().into_os_string(),
21648 ],
21649 )
21650 .unwrap();
21651 let instance_overlay = shared
21652 .install_prepared_launch_overlay(
21653 address.session.instance_id,
21654 PreparedNativeLaunchOverlay::Instance(overlay),
21655 )
21656 .unwrap()
21657 .unwrap();
21658 let bundle = ResolvedBundleReceipt {
21659 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21660 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21661 digest: crate::protocol::SpawnBundleDigest::new(format!(
21662 "sha256:{}",
21663 "0".repeat(64),
21664 ))
21665 .unwrap(),
21666 };
21667 shared.bind_session_with_materialization(
21668 &address,
21669 ProviderRuntimePolicy::raw_pty(),
21670 Some(environment_profile),
21671 Some(bundle),
21672 None,
21673 None,
21674 );
21675 selection.retain();
21676 instance_overlay.retain();
21677
21678 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21679 assert!(matches!(
21680 runtime.remove_native_launch_profile(&native_id),
21681 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21682 ));
21683 assert!(shared.remove_binding(&address).is_some());
21684 assert!(!shared
21685 .native_launch_profile_control
21686 .as_ref()
21687 .unwrap()
21688 .clear_native_instance_launch_overlay(address.session.instance_id));
21689 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21690 }
21691
21692 #[test]
21693 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21694 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21695 let address = SessionAddress {
21696 workspace_id: WorkspaceId::new("primary").unwrap(),
21697 session: SessionKey {
21698 instance_id: AgentInstanceId(201),
21699 generation: SessionGeneration(1),
21700 },
21701 };
21702 let error = match shared.prepare_session_materialization(
21703 &address,
21704 &agent("claude"),
21705 SessionMode::Pty,
21706 Some(&receipt),
21707 None,
21708 None,
21709 None,
21710 ) {
21711 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21712 Err(error) => error,
21713 };
21714 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21715 assert!(shared.handle.snapshot().sessions.is_empty());
21716 assert!(shared
21717 .session_bindings
21718 .lock()
21719 .unwrap_or_else(|poisoned| poisoned.into_inner())
21720 .is_empty());
21721 assert!(shared
21722 .materializations
21723 .lock()
21724 .unwrap_or_else(|poisoned| poisoned.into_inner())
21725 .is_empty());
21726 drop(shared);
21727 drop(runtime);
21728 std::fs::remove_dir_all(root).unwrap();
21729 }
21730
21731 #[test]
21732 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21733 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21734 let address = SessionAddress {
21735 workspace_id: WorkspaceId::new("primary").unwrap(),
21736 session: SessionKey {
21737 instance_id: AgentInstanceId(202),
21738 generation: SessionGeneration(1),
21739 },
21740 };
21741 let (overlay, materialization_guard) = shared
21742 .prepare_session_materialization(
21743 &address,
21744 &agent("claude"),
21745 SessionMode::Pty,
21746 Some(&receipt),
21747 None,
21748 None,
21749 None,
21750 )
21751 .unwrap()
21752 .unwrap();
21753 let materialization_id = materialization_guard.id().unwrap().clone();
21754 let materialization_root = shared
21755 .materializations
21756 .lock()
21757 .unwrap_or_else(|poisoned| poisoned.into_inner())
21758 .get(&materialization_id)
21759 .unwrap()
21760 .root()
21761 .to_path_buf();
21762 let selection = shared
21763 .select_environment_profile(
21764 address.session.instance_id,
21765 &agent("claude"),
21766 SessionMode::Pty,
21767 Some(&receipt),
21768 )
21769 .unwrap()
21770 .unwrap();
21771 assert!(shared
21772 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21773 .unwrap()
21774 .is_none());
21775 shared.bind_session_with_materialization(
21776 &address,
21777 ProviderRuntimePolicy::raw_pty(),
21778 Some(receipt),
21779 None,
21780 None,
21781 Some(materialization_id),
21782 );
21783 selection.retain();
21784 materialization_guard.retain();
21785
21786 assert!(materialization_root.is_dir());
21787 assert!(shared
21788 .session_bindings
21789 .lock()
21790 .unwrap_or_else(|poisoned| poisoned.into_inner())
21791 .contains_key(&address.session.instance_id));
21792
21793 let removed = shared.remove_binding(&address).unwrap();
21794 shared
21795 .cleanup_session_owned_materialization(&address, &removed)
21796 .unwrap();
21797 assert!(!materialization_root.exists());
21798 assert!(shared
21799 .materializations
21800 .lock()
21801 .unwrap_or_else(|poisoned| poisoned.into_inner())
21802 .is_empty());
21803 drop(shared);
21804 drop(runtime);
21805 std::fs::remove_dir_all(root).unwrap();
21806 }
21807
21808 #[tokio::test]
21809 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21810 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21811 let address = SessionAddress {
21812 workspace_id: WorkspaceId::new("primary").unwrap(),
21813 session: SessionKey {
21814 instance_id: AgentInstanceId(203),
21815 generation: SessionGeneration(1),
21816 },
21817 };
21818 let (overlay, materialization_guard) = shared
21819 .prepare_session_materialization(
21820 &address,
21821 &agent("claude"),
21822 SessionMode::Pty,
21823 Some(&receipt),
21824 None,
21825 None,
21826 None,
21827 )
21828 .unwrap()
21829 .unwrap();
21830 let materialization_id = materialization_guard.id().unwrap().clone();
21831 let materialization_root = shared
21832 .materializations
21833 .lock()
21834 .unwrap_or_else(|poisoned| poisoned.into_inner())
21835 .get(&materialization_id)
21836 .unwrap()
21837 .root()
21838 .to_path_buf();
21839 let selection = shared
21840 .select_environment_profile(
21841 address.session.instance_id,
21842 &agent("claude"),
21843 SessionMode::Pty,
21844 Some(&receipt),
21845 )
21846 .unwrap()
21847 .unwrap();
21848 assert!(shared
21849 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21850 .unwrap()
21851 .is_none());
21852 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21853 let record_id = shared
21854 .bind_spawn_session_with_materialization(
21855 &address,
21856 agent("claude"),
21857 SessionMode::Pty,
21858 policy,
21859 SpawnRecordPolicy::ProviderIdentityOnly,
21860 Some(receipt.clone()),
21861 None,
21862 None,
21863 Some(materialization_id.clone()),
21864 )
21865 .unwrap()
21866 .unwrap();
21867 selection.retain();
21868 materialization_guard.retain();
21869 assert_eq!(
21870 shared
21871 .materializations
21872 .lock()
21873 .unwrap_or_else(|poisoned| poisoned.into_inner())
21874 .get(&materialization_id)
21875 .unwrap()
21876 .owner(),
21877 &MaterializationOwner::Record {
21878 record_id: record_id.clone(),
21879 },
21880 );
21881
21882 let removed = shared.remove_binding(&address).unwrap();
21883 shared
21884 .cleanup_session_owned_materialization(&address, &removed)
21885 .unwrap();
21886 assert!(materialization_root.is_dir());
21887 assert!(shared
21888 .resolve_record_materialization(
21889 &record_id,
21890 &agent("claude"),
21891 SessionMode::Pty,
21892 Some(&receipt),
21893 None,
21894 None,
21895 )
21896 .unwrap()
21897 .is_some());
21898 {
21899 let mut records = shared
21900 .session_records
21901 .lock()
21902 .unwrap_or_else(|poisoned| poisoned.into_inner());
21903 let record = records.records.get_mut(&record_id).unwrap();
21904 record.active_session = None;
21905 record.state = ManagedSessionState::Unavailable;
21906 }
21907 shared.forget_session_record(&record_id).await.unwrap();
21908 assert!(!materialization_root.exists());
21909 assert!(shared
21910 .materializations
21911 .lock()
21912 .unwrap_or_else(|poisoned| poisoned.into_inner())
21913 .is_empty());
21914 drop(shared);
21915 drop(runtime);
21916 std::fs::remove_dir_all(root).unwrap();
21917 }
21918
21919 #[test]
21920 fn record_materialization_revalidation_failure_persists_recovery_required() {
21921 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21922 let state_path = root.join("node-state-v6.json");
21923 shared.state_path = Some(state_path.clone());
21924 let (_address, _record_id, materialization_id, materialization_root) =
21925 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21926 std::fs::write(
21927 materialization_root.join(".gate4agent-materialization-owner"),
21928 b"tampered",
21929 )
21930 .unwrap();
21931
21932 let error = match shared.resolve_record_materialization(
21933 &_record_id,
21934 &agent("claude"),
21935 SessionMode::Pty,
21936 Some(&receipt),
21937 None,
21938 None,
21939 ) {
21940 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21941 Err(error) => error,
21942 };
21943 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21944 assert_eq!(
21945 shared
21946 .materializations
21947 .lock()
21948 .unwrap_or_else(|poisoned| poisoned.into_inner())
21949 .get(&materialization_id)
21950 .unwrap()
21951 .state(),
21952 MaterializationState::RecoveryRequired,
21953 );
21954 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21955 assert_eq!(loaded.materializations.len(), 1);
21956 assert_eq!(
21957 loaded.materializations[0].state(),
21958 MaterializationState::RecoveryRequired,
21959 );
21960 drop(shared);
21961 drop(runtime);
21962 std::fs::remove_dir_all(root).unwrap();
21963 }
21964
21965 #[test]
21966 fn transient_record_materialization_resolution_failure_remains_ready() {
21967 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21968 let (_address, record_id, materialization_id, _materialization_root) =
21969 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21970 deny.store(true, Ordering::Release);
21971
21972 let error = match shared.resolve_record_materialization(
21973 &record_id,
21974 &agent("claude"),
21975 SessionMode::Pty,
21976 Some(&receipt),
21977 None,
21978 None,
21979 ) {
21980 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21981 Err(error) => error,
21982 };
21983 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21984 assert_eq!(
21985 shared
21986 .materializations
21987 .lock()
21988 .unwrap_or_else(|poisoned| poisoned.into_inner())
21989 .get(&materialization_id)
21990 .unwrap()
21991 .state(),
21992 MaterializationState::Ready,
21993 );
21994 drop(shared);
21995 drop(runtime);
21996 std::fs::remove_dir_all(root).unwrap();
21997 }
21998
21999 #[test]
22000 fn startup_missing_record_materialization_is_not_resumable() {
22001 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22002 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22003 shared
22004 .insert_record(ManagedSessionRecord {
22005 record_id: record_id.clone(),
22006 display_name: "missing materialization".to_owned(),
22007 provider: agent("claude"),
22008 mode: SessionMode::Pty,
22009 state: ManagedSessionState::Dormant,
22010 workspace_id: WorkspaceId::new("primary").unwrap(),
22011 canonical_root: opaque_windows_path(
22012 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22013 ),
22014 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22015 key: gate4agent_types::ProviderSessionKey::SessionId,
22016 id: "provider-session-missing-materialization".to_owned(),
22017 transcript_path: None,
22018 }),
22019 active_session: None,
22020 environment_profile: Some(receipt),
22021 bundle: None,
22022 context_id: None,
22023 context: None,
22024 exported_context: None,
22025 task_binding: None,
22026 created_at_unix_ms: 1,
22027 updated_at_unix_ms: 1,
22028 last_error: None,
22029 })
22030 .unwrap();
22031
22032 shared.reconcile_materializations();
22033 let record = shared.record(&record_id).unwrap();
22034 assert_eq!(record.state, ManagedSessionState::Unavailable);
22035 assert!(record.provider_session.is_none());
22036 assert_eq!(
22037 record.last_error.as_deref(),
22038 Some("environment-profile-unavailable"),
22039 );
22040 drop(shared);
22041 drop(runtime);
22042 std::fs::remove_dir_all(root).unwrap();
22043 }
22044
22045 #[tokio::test]
22046 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22047 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22048 let lease = install_managed_lease(&shared);
22049 let address = SessionAddress {
22050 workspace_id: WorkspaceId::new("primary").unwrap(),
22051 session: SessionKey {
22052 instance_id: AgentInstanceId(204),
22053 generation: SessionGeneration(1),
22054 },
22055 };
22056 let (overlay, materialization_guard) = shared
22057 .prepare_session_materialization(
22058 &address,
22059 &agent("claude"),
22060 SessionMode::Pty,
22061 Some(&receipt),
22062 None,
22063 None,
22064 Some(lease.lease_id.clone()),
22065 )
22066 .unwrap()
22067 .unwrap();
22068 let materialization_id = materialization_guard.id().unwrap().clone();
22069 let materialization_root = shared
22070 .materializations
22071 .lock()
22072 .unwrap_or_else(|poisoned| poisoned.into_inner())
22073 .get(&materialization_id)
22074 .unwrap()
22075 .root()
22076 .to_path_buf();
22077 let selection = shared
22078 .select_environment_profile(
22079 address.session.instance_id,
22080 &agent("claude"),
22081 SessionMode::Pty,
22082 Some(&receipt),
22083 )
22084 .unwrap()
22085 .unwrap();
22086 assert!(shared
22087 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22088 .unwrap()
22089 .is_none());
22090 shared.bind_session_with_materialization(
22091 &address,
22092 ProviderRuntimePolicy::raw_pty(),
22093 Some(receipt),
22094 None,
22095 None,
22096 Some(materialization_id),
22097 );
22098 selection.retain();
22099 materialization_guard.retain();
22100
22101 let blocked = shared
22102 .cleanup_managed_worktree(&lease.lease_id, false)
22103 .await
22104 .unwrap_err();
22105 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22106 assert!(materialization_root.is_dir());
22107 let removed = shared.remove_binding(&address).unwrap();
22108 shared
22109 .cleanup_session_owned_materialization(&address, &removed)
22110 .unwrap();
22111 assert!(!materialization_root.exists());
22112 drop(shared);
22113 drop(runtime);
22114 std::fs::remove_dir_all(root).unwrap();
22115 }
22116
22117 #[tokio::test]
22118 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22119 let git_root = temporary_workspace_root("raw-managed-inventory");
22120 let source = git_root.join("source");
22121 let allocation = git_root.join("allocation");
22122 std::fs::create_dir_all(&source).unwrap();
22123 std::fs::create_dir_all(&allocation).unwrap();
22124 run_test_git(&source, &["init"]);
22125 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22126 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22127 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22128 run_test_git(&source, &["add", "seed.txt"]);
22129 run_test_git(&source, &["commit", "-m", "seed"]);
22130 let profile = ManagedWorktreeProfile::new(
22131 WorktreeProfileId::new("default").unwrap(),
22132 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22133 &allocation,
22134 "gate4agent",
22135 "HEAD",
22136 ManagedWorktreeRetention::RemoveWhenReleased,
22137 )
22138 .unwrap();
22139 let workspace = WorkspaceConfig::new(
22140 WorkspaceId::new("primary").unwrap(),
22141 &source,
22142 )
22143 .unwrap()
22144 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22145 .with_managed_worktree_profile(profile.clone())
22146 .unwrap();
22147 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22148 materialization_test_shared_with_workspace(false, workspace);
22149 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22150 let source_text = source.to_string_lossy().into_owned();
22151 let target_text = target.to_string_lossy().into_owned();
22152 let created = create_git_worktree(
22153 &source_text,
22154 &target_text,
22155 "gate4agent/mw-raw-inventory",
22156 Some("HEAD"),
22157 )
22158 .await
22159 .unwrap();
22160 let lease = ManagedWorktreeLeaseRecord {
22161 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22162 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22163 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22164 profile_id: profile.profile_id().clone(),
22165 profile_revision: profile.revision().clone(),
22166 target_root: created.path.clone(),
22167 branch: "gate4agent/mw-raw-inventory".to_owned(),
22168 base_commit: created.head.clone(),
22169 expected_head: Some(created.head.clone()),
22170 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22171 state: ManagedWorktreeLeaseState::Ready,
22172 session_holders: Vec::new(),
22173 record_holders: Vec::new(),
22174 cleanup_failure: None,
22175 created_at_unix_ms: 1,
22176 updated_at_unix_ms: 1,
22177 };
22178 shared
22179 .workspaces
22180 .write()
22181 .unwrap_or_else(|poisoned| poisoned.into_inner())
22182 .insert(lease.workspace_id.clone(), created.path.clone());
22183 *shared
22184 .managed_worktrees
22185 .lock()
22186 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22187 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22188 let address = SessionAddress {
22189 workspace_id: lease.workspace_id.clone(),
22190 session: SessionKey {
22191 instance_id: AgentInstanceId(205),
22192 generation: SessionGeneration(1),
22193 },
22194 };
22195 let (overlay, materialization_guard) = shared
22196 .prepare_session_materialization(
22197 &address,
22198 &agent("claude"),
22199 SessionMode::Pty,
22200 Some(&receipt),
22201 None,
22202 None,
22203 Some(lease.lease_id.clone()),
22204 )
22205 .unwrap()
22206 .unwrap();
22207 let materialization_id = materialization_guard.id().unwrap().clone();
22208 let materialization_root = shared
22209 .materializations
22210 .lock()
22211 .unwrap_or_else(|poisoned| poisoned.into_inner())
22212 .get(&materialization_id)
22213 .unwrap()
22214 .root()
22215 .to_path_buf();
22216 let selection = shared
22217 .select_environment_profile(
22218 address.session.instance_id,
22219 &agent("claude"),
22220 SessionMode::Pty,
22221 Some(&receipt),
22222 )
22223 .unwrap()
22224 .unwrap();
22225 assert!(shared
22226 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22227 .unwrap()
22228 .is_none());
22229 let record_id = shared
22230 .bind_spawn_session_with_materialization(
22231 &address,
22232 agent("claude"),
22233 SessionMode::Pty,
22234 ProviderRuntimePolicy::raw_pty(),
22235 SpawnRecordPolicy::Always,
22236 Some(receipt.clone()),
22237 None,
22238 None,
22239 Some(materialization_id.clone()),
22240 )
22241 .unwrap()
22242 .unwrap();
22243 selection.retain();
22244 materialization_guard.retain();
22245 let record = shared.record(&record_id).unwrap();
22246 assert_eq!(record.state, ManagedSessionState::Live);
22247 assert!(record.provider_session.is_none());
22248 assert!(record.environment_profile.is_none());
22249 assert!(record.bundle.is_none());
22250 assert!(record.context_id.is_none());
22251 assert!(record.context.is_none());
22252 assert_eq!(record.active_session.as_ref(), Some(&address));
22253 assert_eq!(
22254 shared
22255 .materializations
22256 .lock()
22257 .unwrap_or_else(|poisoned| poisoned.into_inner())
22258 .get(&materialization_id)
22259 .unwrap()
22260 .owner(),
22261 &MaterializationOwner::Session {
22262 incarnation_id: shared.incarnation_id,
22263 instance_id: address.session.instance_id,
22264 generation: address.session.generation,
22265 },
22266 );
22267 {
22268 let mut bindings = shared
22269 .session_bindings
22270 .lock()
22271 .unwrap_or_else(|poisoned| poisoned.into_inner());
22272 bindings
22273 .get_mut(&address.session.instance_id)
22274 .unwrap()
22275 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22276 }
22277 let lease_snapshot = shared
22278 .managed_worktrees
22279 .lock()
22280 .unwrap_or_else(|poisoned| poisoned.into_inner())
22281 .bind_session(
22282 &lease.lease_id,
22283 ManagedWorktreeSessionHolder {
22284 incarnation_id: shared.incarnation_id,
22285 instance_id: address.session.instance_id,
22286 generation: address.session.generation,
22287 },
22288 None,
22289 unix_time_ms(),
22290 )
22291 .unwrap();
22292 assert_eq!(lease_snapshot.active_session_count, 1);
22293 assert_eq!(lease_snapshot.managed_record_count, 0);
22294 shared.persist_state().unwrap();
22295 assert!(shared
22296 .managed_worktrees
22297 .lock()
22298 .unwrap_or_else(|poisoned| poisoned.into_inner())
22299 .get(&lease.lease_id)
22300 .unwrap()
22301 .record_holders
22302 .is_empty());
22303 shared
22304 .handle
22305 .dispatch(shared.prepare_command(ControlCommand::Register {
22306 instance_id: address.session.instance_id,
22307 agent_id: agent("claude"),
22308 transport: TransportKind::Pty,
22309 }))
22310 .unwrap();
22311 runtime.tick().await;
22312 shared.reconcile_managed_record(
22313 &record_id,
22314 &address,
22315 &ControlEvent {
22316 sequence: 1,
22317 command_id: None,
22318 instance_id: address.session.instance_id,
22319 generation: address.session.generation,
22320 event: ControlEventKind::Removed,
22321 },
22322 false,
22323 false,
22324 );
22325 let remove = shared.remove_session(&address);
22326 let drive_runtime = async {
22327 runtime.tick().await;
22328 };
22329 let (removed, ()) = tokio::join!(remove, drive_runtime);
22330 removed.unwrap();
22331
22332 assert!(!materialization_root.exists());
22333 assert!(!Path::new(&created.path).exists());
22334 assert_eq!(
22335 shared
22336 .managed_worktrees
22337 .lock()
22338 .unwrap_or_else(|poisoned| poisoned.into_inner())
22339 .get(&lease.lease_id)
22340 .unwrap()
22341 .state,
22342 ManagedWorktreeLeaseState::Removed,
22343 );
22344 let renamed = shared
22345 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22346 .unwrap();
22347 assert_eq!(renamed.record_id, record_id);
22348 assert_eq!(renamed.display_name, "raw managed inventory");
22349 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22350 assert!(renamed.active_session.is_none());
22351
22352 drop(shared);
22353 drop(runtime);
22354 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22355 std::fs::remove_dir_all(git_root).unwrap();
22356 }
22357
22358 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22359 let root = std::env::current_dir().unwrap()
22360 .join("managed-test-target")
22361 .to_string_lossy()
22362 .into_owned();
22363 let lease = ManagedWorktreeLeaseRecord {
22364 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22365 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22366 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22367 profile_id: WorktreeProfileId::new("default").unwrap(),
22368 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22369 target_root: root.clone(),
22370 branch: "gate4agent/mw-test".to_owned(),
22371 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22372 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22373 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22374 state: ManagedWorktreeLeaseState::Ready,
22375 session_holders: Vec::new(),
22376 record_holders: Vec::new(),
22377 cleanup_failure: None,
22378 created_at_unix_ms: 1,
22379 updated_at_unix_ms: 1,
22380 };
22381 shared.workspaces.write()
22382 .unwrap_or_else(|poisoned| poisoned.into_inner())
22383 .insert(lease.workspace_id.clone(), root);
22384 *shared.managed_worktrees.lock()
22385 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22386 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22387 lease
22388 }
22389
22390 #[cfg(feature = "fixture")]
22391 #[test]
22392 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22393 let mut shared = terminal_test_shared();
22394 shared.fixture_semantic_hook_policy = true;
22395 let lease = install_managed_lease(&shared);
22396 let address = SessionAddress {
22397 workspace_id: lease.workspace_id.clone(),
22398 session: SessionKey {
22399 instance_id: AgentInstanceId(206),
22400 generation: SessionGeneration(1),
22401 },
22402 };
22403 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22404 let effective_runtime_policy =
22405 shared.effective_spawn_runtime_policy(pre_admission_policy);
22406 assert_ne!(effective_runtime_policy, pre_admission_policy);
22407 assert!(effective_runtime_policy.provider_session_identity);
22408
22409 let record_id = shared
22410 .bind_spawn_session(
22411 &address,
22412 agent("claude"),
22413 SessionMode::Pty,
22414 effective_runtime_policy,
22415 SpawnRecordPolicy::Always,
22416 None,
22417 )
22418 .unwrap()
22419 .unwrap();
22420 assert_eq!(
22421 shared.record(&record_id).unwrap().state,
22422 ManagedSessionState::IdentityPending,
22423 );
22424 let (record_holder, raw_inventory_record_id) = {
22425 let mut bindings = shared
22426 .session_bindings
22427 .lock()
22428 .unwrap_or_else(|poisoned| poisoned.into_inner());
22429 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22430 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22431 managed_spawn_record_ownership(binding, effective_runtime_policy)
22432 };
22433 assert_eq!(record_holder.as_ref(), Some(&record_id));
22434 assert!(raw_inventory_record_id.is_none());
22435 let snapshot = shared
22436 .managed_worktrees
22437 .lock()
22438 .unwrap_or_else(|poisoned| poisoned.into_inner())
22439 .bind_session(
22440 &lease.lease_id,
22441 ManagedWorktreeSessionHolder {
22442 incarnation_id: shared.incarnation_id,
22443 instance_id: address.session.instance_id,
22444 generation: address.session.generation,
22445 },
22446 record_holder,
22447 unix_time_ms(),
22448 )
22449 .unwrap();
22450 assert_eq!(snapshot.managed_record_count, 1);
22451 assert_eq!(
22452 shared
22453 .managed_worktrees
22454 .lock()
22455 .unwrap_or_else(|poisoned| poisoned.into_inner())
22456 .get(&lease.lease_id)
22457 .unwrap()
22458 .record_holders,
22459 vec![record_id.clone()],
22460 );
22461
22462 let identity = ProviderSessionIdentity {
22463 key: ProviderSessionKey::SessionId,
22464 id: "monitoring-fixture-managed-session".to_owned(),
22465 transcript_path: None,
22466 };
22467 shared.publish_control(ControlEvent {
22468 sequence: 1,
22469 command_id: None,
22470 instance_id: address.session.instance_id,
22471 generation: address.session.generation,
22472 event: ControlEventKind::ProviderEvent {
22473 sequence: 1,
22474 source: gate4agent_types::ProviderSource {
22475 family: AdapterFamily::Hook,
22476 binding: AdapterBinding::new(
22477 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22478 "fixture/v1",
22479 gate4agent_types::AdapterVerification::SyntheticFixture,
22480 )
22481 .unwrap(),
22482 },
22483 source_sequence: 1,
22484 event: ProviderEvent::SessionIdentityObserved {
22485 identity: identity.clone(),
22486 },
22487 },
22488 });
22489
22490 let live = shared.record(&record_id).unwrap();
22491 assert_eq!(live.state, ManagedSessionState::Live);
22492 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22493 assert_eq!(live.active_session.as_ref(), Some(&address));
22494 assert_eq!(
22495 shared
22496 .managed_worktrees
22497 .lock()
22498 .unwrap_or_else(|poisoned| poisoned.into_inner())
22499 .get(&lease.lease_id)
22500 .unwrap()
22501 .record_holders,
22502 vec![record_id],
22503 );
22504 }
22505
22506 fn managed_v2_test_shared(
22507 allocation_root: &Path,
22508 state_path: Option<PathBuf>,
22509 profile_revision: &str,
22510 ) -> NodeShared {
22511 let profile = ManagedWorktreeProfile::new(
22512 WorktreeProfileId::new("default").unwrap(),
22513 WorktreeProfileRevision::new(profile_revision).unwrap(),
22514 allocation_root,
22515 "gate4agent",
22516 "HEAD",
22517 ManagedWorktreeRetention::RemoveWhenReleased,
22518 )
22519 .unwrap();
22520 let workspace = WorkspaceConfig::new(
22521 WorkspaceId::new("primary").unwrap(),
22522 std::env::current_dir().unwrap(),
22523 )
22524 .unwrap()
22525 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22526 .with_managed_worktree_profile(profile)
22527 .unwrap();
22528 let catalog = active_registry().unwrap();
22529 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22530 let mut shared = NodeShared::new(
22531 handle,
22532 "fixture-token".to_owned(),
22533 NodeId::new("node-terminal-test").unwrap(),
22534 vec![workspace],
22535 vec![agent("claude")],
22536 );
22537 shared.state_path = state_path;
22538 shared
22539 }
22540
22541 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22542 let mut spawn_spec = spawn_spec_fixture();
22543 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22544 ManagedWorktreeSpawnRequestV2 {
22545 spawn_spec,
22546 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22547 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22548 }
22549 }
22550
22551 #[test]
22552 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22553 let shared = terminal_test_shared();
22554 let mut lease = install_managed_lease(&shared);
22555 let session = SessionAddress {
22556 workspace_id: lease.workspace_id.clone(),
22557 session: SessionKey {
22558 instance_id: AgentInstanceId(73),
22559 generation: SessionGeneration(4),
22560 },
22561 };
22562 lease.state = ManagedWorktreeLeaseState::InUse;
22563 lease.session_holders.push(ManagedWorktreeSessionHolder {
22564 incarnation_id: shared.incarnation_id,
22565 instance_id: session.session.instance_id,
22566 generation: session.session.generation,
22567 });
22568 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22569 assert!(resolved.target.worktree_id.is_none());
22570
22571 let receipt = managed_spawn_receipt(
22572 &resolved,
22573 shared.incarnation_id,
22574 session.clone(),
22575 lease.snapshot(),
22576 None,
22577 None,
22578 None,
22579 );
22580 assert!(resolved.target.worktree_id.is_none());
22581 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22582 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22583 assert_eq!(receipt.spawn.session, session);
22584 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22585 let encoded = serde_json::to_vec(&receipt).unwrap();
22586 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22587 assert_eq!(decoded, receipt);
22588 }
22589
22590 #[tokio::test]
22591 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22592 let root = temporary_workspace_root("managed-v2-revision");
22593 let allocation = root.join("allocation");
22594 std::fs::create_dir_all(&allocation).unwrap();
22595 let shared = managed_v2_test_shared(
22596 &allocation,
22597 Some(root.join("node-state.json")),
22598 "v1",
22599 );
22600 let mut request = managed_v2_request("managed-v2-revision");
22601 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22602
22603 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22604
22605 assert_eq!(
22606 failure.code,
22607 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22608 );
22609 assert!(shared
22610 .managed_worktrees
22611 .lock()
22612 .unwrap_or_else(|poisoned| poisoned.into_inner())
22613 .records()
22614 .is_empty());
22615 assert!(shared
22616 .managed_spawn_replays
22617 .lock()
22618 .unwrap_or_else(|poisoned| poisoned.into_inner())
22619 .is_empty());
22620 std::fs::remove_dir_all(root).unwrap();
22621 }
22622
22623 #[test]
22624 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22625 let root = temporary_workspace_root("managed-v2-concurrent");
22626 let allocation = root.join("allocation");
22627 std::fs::create_dir_all(&allocation).unwrap();
22628 let shared = Arc::new(managed_v2_test_shared(
22629 &allocation,
22630 Some(root.join("node-state.json")),
22631 "v1",
22632 ));
22633 let request = managed_v2_request("managed-v2-concurrent");
22634 let barrier = Arc::new(std::sync::Barrier::new(3));
22635 let mut joins = Vec::new();
22636 for _ in 0..2 {
22637 let shared = Arc::clone(&shared);
22638 let request = request.clone();
22639 let barrier = Arc::clone(&barrier);
22640 joins.push(std::thread::spawn(move || {
22641 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22642 barrier.wait();
22643 shared.acquire_durable_managed_spawn_v2(&request, digest)
22644 }));
22645 }
22646 barrier.wait();
22647 let results = joins
22648 .into_iter()
22649 .map(|join| join.join().unwrap())
22650 .collect::<Vec<_>>();
22651 assert_eq!(
22652 results
22653 .iter()
22654 .filter(|result| {
22655 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22656 })
22657 .count(),
22658 1,
22659 );
22660 assert_eq!(
22661 results
22662 .iter()
22663 .filter(|result| {
22664 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22665 })
22666 .count(),
22667 1,
22668 );
22669 assert_eq!(
22670 shared
22671 .managed_spawn_replays
22672 .lock()
22673 .unwrap_or_else(|poisoned| poisoned.into_inner())
22674 .len(),
22675 1,
22676 );
22677 assert!(shared
22678 .managed_worktrees
22679 .lock()
22680 .unwrap_or_else(|poisoned| poisoned.into_inner())
22681 .records()
22682 .is_empty());
22683 drop(shared);
22684 std::fs::remove_dir_all(root).unwrap();
22685 }
22686
22687 #[tokio::test]
22688 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22689 let root = temporary_workspace_root("managed-v2-linked-pending");
22690 let allocation = root.join("allocation");
22691 std::fs::create_dir_all(&allocation).unwrap();
22692 let state_path = root.join("node-state.json");
22693 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22694 let lease = install_managed_lease(&shared);
22695 let request = managed_v2_request("managed-v2-linked-pending");
22696 assert!(matches!(
22697 shared
22698 .acquire_durable_managed_spawn_v2(
22699 &request,
22700 managed_spawn_request_digest_v2(&request).unwrap(),
22701 )
22702 .unwrap(),
22703 DurableManagedSpawnReplayDecision::Reserved,
22704 ));
22705 {
22706 let _transaction = shared
22707 .state_transaction
22708 .lock()
22709 .unwrap_or_else(|poisoned| poisoned.into_inner());
22710 shared
22711 .link_durable_managed_spawn_v2_lease_locked(
22712 &request.spawn_spec.idempotency_key,
22713 &lease.lease_id,
22714 )
22715 .unwrap();
22716 shared.persist_state_locked().unwrap();
22717 }
22718 drop(shared);
22719
22720 let mut loaded = session_registry::load(
22721 Some(&state_path),
22722 &NodeId::new("node-terminal-test").unwrap(),
22723 )
22724 .unwrap();
22725 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22726 *reopened
22727 .managed_worktrees
22728 .lock()
22729 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22730 ManagedWorktreeRegistry::from_records(
22731 std::mem::take(&mut loaded.managed_worktrees),
22732 std::mem::take(&mut loaded.managed_worktree_tombstones),
22733 )
22734 .unwrap();
22735 *reopened
22736 .managed_spawn_replays
22737 .lock()
22738 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22739 .managed_spawn_replays
22740 .into_iter()
22741 .map(|record| (record.idempotency_key.clone(), record))
22742 .collect();
22743 let lease_count = reopened
22744 .managed_worktrees
22745 .lock()
22746 .unwrap_or_else(|poisoned| poisoned.into_inner())
22747 .records()
22748 .len();
22749
22750 let failure = reopened
22751 .spawn_managed_worktree_v2(request)
22752 .await
22753 .unwrap_err();
22754
22755 assert_eq!(
22756 failure.code,
22757 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22758 );
22759 assert_eq!(
22760 reopened
22761 .managed_worktrees
22762 .lock()
22763 .unwrap_or_else(|poisoned| poisoned.into_inner())
22764 .records()
22765 .len(),
22766 lease_count,
22767 );
22768 std::fs::remove_dir_all(root).unwrap();
22769 }
22770
22771 #[tokio::test]
22772 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22773 let root = temporary_workspace_root("managed-v2-reopen");
22774 let allocation = root.join("allocation");
22775 std::fs::create_dir_all(&allocation).unwrap();
22776 let state_path = root.join("node-state.json");
22777 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22778 let lease = install_managed_lease(&shared);
22779 let request = managed_v2_request("managed-v2-reopen");
22780 let session = SessionAddress {
22781 workspace_id: lease.workspace_id.clone(),
22782 session: SessionKey {
22783 instance_id: AgentInstanceId(91),
22784 generation: SessionGeneration(1),
22785 },
22786 };
22787 let snapshot = {
22788 let mut registry = shared
22789 .managed_worktrees
22790 .lock()
22791 .unwrap_or_else(|poisoned| poisoned.into_inner());
22792 let current = registry.get_mut(&lease.lease_id).unwrap();
22793 current.state = ManagedWorktreeLeaseState::InUse;
22794 current.session_holders.push(ManagedWorktreeSessionHolder {
22795 incarnation_id: shared.incarnation_id,
22796 instance_id: session.session.instance_id,
22797 generation: session.session.generation,
22798 });
22799 current.snapshot()
22800 };
22801 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22802 let receipt = managed_spawn_receipt(
22803 &resolved,
22804 shared.incarnation_id,
22805 session,
22806 snapshot,
22807 None,
22808 None,
22809 None,
22810 );
22811 assert!(matches!(
22812 shared
22813 .acquire_durable_managed_spawn_v2(
22814 &request,
22815 managed_spawn_request_digest_v2(&request).unwrap(),
22816 )
22817 .unwrap(),
22818 DurableManagedSpawnReplayDecision::Reserved,
22819 ));
22820 {
22821 let _transaction = shared
22822 .state_transaction
22823 .lock()
22824 .unwrap_or_else(|poisoned| poisoned.into_inner());
22825 shared
22826 .link_durable_managed_spawn_v2_lease_locked(
22827 &request.spawn_spec.idempotency_key,
22828 &lease.lease_id,
22829 )
22830 .unwrap();
22831 shared.persist_state_locked().unwrap();
22832 }
22833 shared
22834 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22835 .unwrap();
22836 let legacy_same_key = ManagedWorktreeSpawnRequest {
22837 spawn_spec: request.spawn_spec.clone(),
22838 worktree_profile_id: request.worktree_profile_id.clone(),
22839 };
22840 shared.remember_managed_spawn_attempt(
22841 Some(&request.spawn_spec.idempotency_key),
22842 legacy_same_key.clone(),
22843 Ok(receipt.clone()),
22844 Instant::now(),
22845 );
22846 assert!(shared
22847 .replay_managed_spawn(&legacy_same_key, Instant::now())
22848 .unwrap()
22849 .is_none());
22850 drop(shared);
22851
22852 let mut loaded = session_registry::load(
22853 Some(&state_path),
22854 &NodeId::new("node-terminal-test").unwrap(),
22855 )
22856 .unwrap();
22857 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22858 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22859 *reopened
22860 .managed_worktrees
22861 .lock()
22862 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22863 ManagedWorktreeRegistry::from_records(
22864 std::mem::take(&mut loaded.managed_worktrees),
22865 std::mem::take(&mut loaded.managed_worktree_tombstones),
22866 )
22867 .unwrap();
22868 *reopened
22869 .managed_spawn_replays
22870 .lock()
22871 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22872 .managed_spawn_replays
22873 .into_iter()
22874 .map(|record| (record.idempotency_key.clone(), record))
22875 .collect();
22876 let lease_count_before = reopened
22877 .managed_worktrees
22878 .lock()
22879 .unwrap_or_else(|poisoned| poisoned.into_inner())
22880 .records()
22881 .len();
22882
22883 assert_eq!(
22884 reopened
22885 .spawn_managed_worktree_v2(request.clone())
22886 .await
22887 .unwrap(),
22888 receipt,
22889 );
22890 assert_eq!(
22891 reopened
22892 .managed_worktrees
22893 .lock()
22894 .unwrap_or_else(|poisoned| poisoned.into_inner())
22895 .records()
22896 .len(),
22897 lease_count_before,
22898 );
22899 let mut changed = request;
22900 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22901 let failure = reopened
22902 .spawn_managed_worktree_v2(changed)
22903 .await
22904 .unwrap_err();
22905 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22906 assert_eq!(
22907 reopened
22908 .managed_worktrees
22909 .lock()
22910 .unwrap_or_else(|poisoned| poisoned.into_inner())
22911 .records()
22912 .len(),
22913 lease_count_before,
22914 );
22915 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22916 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22917 let failure = reopened
22918 .spawn_managed_worktree_v2(new_key)
22919 .await
22920 .unwrap_err();
22921 assert_eq!(
22922 failure.code,
22923 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22924 );
22925 assert_eq!(
22926 reopened
22927 .managed_worktrees
22928 .lock()
22929 .unwrap_or_else(|poisoned| poisoned.into_inner())
22930 .records()
22931 .len(),
22932 lease_count_before,
22933 );
22934 assert_eq!(
22935 reopened
22936 .managed_spawn_replays
22937 .lock()
22938 .unwrap_or_else(|poisoned| poisoned.into_inner())
22939 .len(),
22940 1,
22941 );
22942 std::fs::remove_dir_all(root).unwrap();
22943 }
22944
22945 #[test]
22946 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22947 let shared = terminal_test_shared();
22948 let request = ManagedWorktreeSpawnRequest {
22949 spawn_spec: spawn_spec_fixture(),
22950 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22951 };
22952 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22953 shared.remember_managed_spawn(
22954 request.clone(),
22955 Err(expected.clone()),
22956 Instant::now(),
22957 );
22958
22959 assert_eq!(
22960 shared
22961 .replay_managed_spawn(&request, Instant::now())
22962 .unwrap()
22963 .unwrap(),
22964 Err(expected),
22965 );
22966 assert!(shared
22967 .managed_spawn_replays
22968 .lock()
22969 .unwrap_or_else(|poisoned| poisoned.into_inner())
22970 .is_empty());
22971 }
22972
22973 #[tokio::test]
22974 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22975 let shared = terminal_test_shared();
22976 let lease = install_managed_lease(&shared);
22977 let raw = shared.spawn_session(
22978 lease.workspace_id.clone(),
22979 agent("claude"),
22980 SessionMode::Pty,
22981 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22982 None,
22983 ).await.unwrap_err();
22984 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22985 let mut spec = spawn_spec_fixture();
22986 spec.target.workspace_id = lease.workspace_id.clone();
22987 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22988 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22989 let mut selected = spawn_spec_fixture();
22990 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22991 selected.target.worktree_id = Some(lease.workspace_id);
22992 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22993 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22994 assert!(shared.handle.snapshot().sessions.is_empty());
22995 }
22996
22997 #[test]
22998 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22999 let shared = terminal_test_shared();
23000 let lease = install_managed_lease(&shared);
23001 let snapshot = snapshot_for_wire(
23002 &shared, true, true, true, false, true, true, false, false, true,
23003 );
23004 assert!(snapshot.managed_worktrees.is_empty());
23005 assert!(snapshot.workspaces.iter().all(|workspace| {
23006 workspace.managed_worktree_profiles.is_none()
23007 }));
23008 let mut reply = ResponseEnvelope {
23009 request_id: 1,
23010 result: Ok(NodeResponse::Resync {
23011 event_sequence: 1,
23012 oldest_available_sequence: 1,
23013 snapshot: shared.snapshot(),
23014 events: vec![NodeEventEnvelope {
23015 sequence: 1,
23016 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23017 }],
23018 }),
23019 };
23020 project_response_without_managed_worktrees(&mut reply);
23021 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23022 panic!("projection changed the response kind");
23023 };
23024 assert!(snapshot.managed_worktrees.is_empty());
23025 assert!(events.is_empty());
23026 let mut git = git_snapshot_for_parser();
23027 git.branch = Some(lease.branch.clone());
23028 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23029 lease_id: lease.lease_id.clone(),
23030 source_workspace_id: lease.source_workspace_id.clone(),
23031 branch: lease.branch.clone(),
23032 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23033 active_session_count: 1,
23034 managed_record_count: 0,
23035 });
23036 let mut inspection_reply = ResponseEnvelope {
23037 request_id: 2,
23038 result: Ok(NodeResponse::WorkspaceInspected {
23039 inspection: WorkspaceInspection {
23040 workspace_id: lease.workspace_id.clone(),
23041 entries: Vec::new(),
23042 tree_truncated: false,
23043 git,
23044 truncation: None,
23045 },
23046 }),
23047 };
23048 project_response_without_managed_worktrees(&mut inspection_reply);
23049 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23050 panic!("projection changed the inspection response kind");
23051 };
23052 assert!(inspection.git.managed_worktree.is_none());
23053 let failure = managed_git_worktree_failure(GitWorktreeError {
23054 kind: GitWorktreeErrorKind::Failed,
23055 message: r"git failed at C:\private\managed-target".to_owned(),
23056 });
23057 assert!(!failure.message.contains("private"));
23058 assert!(!failure.message.contains("managed-target"));
23059 }
23060
23061 #[tokio::test]
23062 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23063 let root = temporary_workspace_root("managed-restart");
23064 let source = root.join("source");
23065 let allocation = root.join("allocation");
23066 std::fs::create_dir_all(&source).unwrap();
23067 std::fs::create_dir_all(&allocation).unwrap();
23068 run_test_git(&source, &["init"]);
23069 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23070 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23071 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23072 run_test_git(&source, &["add", "seed.txt"]);
23073 run_test_git(&source, &["commit", "-m", "seed"]);
23074 let profile = ManagedWorktreeProfile::new(
23075 WorktreeProfileId::new("default").unwrap(),
23076 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23077 &allocation,
23078 "gate4agent",
23079 "HEAD",
23080 ManagedWorktreeRetention::Retain,
23081 ).unwrap();
23082 let workspace = WorkspaceConfig::new(
23083 WorkspaceId::new("primary").unwrap(),
23084 &source,
23085 ).unwrap()
23086 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23087 .with_managed_worktree_profile(profile.clone()).unwrap();
23088 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23089 let source_text = workspace.canonical_root().to_owned();
23090 let target_text = target.to_string_lossy().into_owned();
23091 let created = create_git_worktree(
23092 &source_text,
23093 &target_text,
23094 "gate4agent/mw-restart",
23095 Some("HEAD"),
23096 ).await.unwrap();
23097 let lease = ManagedWorktreeLeaseRecord {
23098 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23099 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23100 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23101 profile_id: profile.profile_id().clone(),
23102 profile_revision: profile.revision().clone(),
23103 target_root: created.path.clone(),
23104 branch: "gate4agent/mw-restart".to_owned(),
23105 base_commit: created.head.clone(),
23106 expected_head: None,
23107 retention: ManagedWorktreeRetention::Retain,
23108 state: ManagedWorktreeLeaseState::Allocating,
23109 session_holders: Vec::new(),
23110 record_holders: Vec::new(),
23111 cleanup_failure: None,
23112 created_at_unix_ms: 1,
23113 updated_at_unix_ms: 1,
23114 };
23115 let catalog = active_registry().unwrap();
23116 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23117 let shared = NodeShared::new_with_incarnation(
23118 handle,
23119 "fixture-token".to_owned(),
23120 NodeId::new("node-managed-restart").unwrap(),
23121 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23122 vec![workspace],
23123 vec![agent("claude")],
23124 ProviderRuntimeStatuses::default(),
23125 None,
23126 Vec::new(),
23127 Vec::new(),
23128 SpawnProfileRegistry::default(),
23129 None,
23130 None,
23131 None,
23132 None,
23133 Vec::new(),
23134 vec![lease.clone()],
23135 Vec::new(),
23136 Vec::new(),
23137 None,
23138 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23139 );
23140 shared.reconcile_managed_worktrees().await;
23141 let retained = shared.managed_worktrees.lock()
23142 .unwrap_or_else(|poisoned| poisoned.into_inner())
23143 .get(&lease.lease_id).unwrap().clone();
23144 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23145 assert!(shared.workspaces.read()
23146 .unwrap_or_else(|poisoned| poisoned.into_inner())
23147 .contains_key(&lease.workspace_id));
23148 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23149 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23150 assert!(!Path::new(&created.path).exists());
23151 std::fs::remove_dir_all(root).unwrap();
23152 }
23153
23154 fn run_test_git(root: &Path, arguments: &[&str]) {
23155 let output = std::process::Command::new("git")
23156 .arg("-C").arg(root).args(arguments).output().unwrap();
23157 assert!(
23158 output.status.success(),
23159 "git {:?} failed: {}",
23160 arguments,
23161 String::from_utf8_lossy(&output.stderr),
23162 );
23163 }
23164
23165 #[tokio::test]
23166 async fn spawn_spec_validation_fails_before_session_mutation() {
23167 let shared = terminal_test_shared();
23168 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23169 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23170 }));
23171 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23172 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23173 }));
23174 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23175 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23176 }));
23177
23178 let mut target_mismatch = spawn_spec_fixture();
23179 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23180 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23181 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23182
23183 let mut unknown_profile = spawn_spec_fixture();
23184 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23185 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23186 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23187 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23188
23189 let mut revision_mismatch = spawn_spec_fixture();
23190 revision_mismatch.idempotency_key =
23191 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23192 revision_mismatch.expected_profile_revision =
23193 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23194 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23195 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23196
23197 let mut unsupported_materializer = spawn_spec_fixture();
23198 unsupported_materializer.idempotency_key =
23199 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23200 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23201 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23202 };
23203 let failure = shared
23204 .spawn_from_spec(unsupported_materializer)
23205 .await
23206 .unwrap_err();
23207 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23208
23209 let failure = shared
23210 .spawn_session_with_deadline(
23211 WorkspaceId::new("primary").unwrap(),
23212 agent("claude"),
23213 SessionMode::Pty,
23214 gate4agent_types::TerminalSize {
23215 rows: 24,
23216 columns: 80,
23217 },
23218 None,
23219 None,
23220 None,
23221 None,
23222 None,
23223 None,
23224 SpawnRecordPolicy::ProviderIdentityOnly,
23225 Some(Instant::now()),
23226 &[],
23227 None,
23228 ApprovalLevel::default(),
23229 None,
23230 None,
23231 )
23232 .await
23233 .unwrap_err();
23234 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23235 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23236 assert!(shared.handle.snapshot().sessions.is_empty());
23237 }
23238
23239 #[test]
23240 fn worktree_selection_requires_its_separate_negotiated_capability() {
23241 let mut spec = spawn_spec_fixture();
23242 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23243 let request = NodeRequest::SpawnSpec { spec };
23244 let spawn_spec_capability =
23245 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23246 assert!(request_uses_unnegotiated_capability(
23247 &request,
23248 &[spawn_spec_capability.clone()],
23249 ));
23250 let worktree_capability =
23251 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23252 assert!(request_uses_unnegotiated_capability(
23253 &request,
23254 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23255 ));
23256 let profile_revision_capability =
23257 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23258 assert!(request_uses_unnegotiated_capability(
23259 &request,
23260 &[
23261 spawn_spec_capability.clone(),
23262 worktree_capability.clone(),
23263 profile_revision_capability.clone(),
23264 ],
23265 ));
23266 let bundle_materialization_capability =
23273 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23274 assert!(request_uses_unnegotiated_capability(
23275 &request,
23276 &[
23277 spawn_spec_capability.clone(),
23278 worktree_capability.clone(),
23279 profile_revision_capability.clone(),
23280 bundle_materialization_capability.clone(),
23281 ],
23282 ));
23283 let history_context_pack_capability =
23284 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23285 assert!(!request_uses_unnegotiated_capability(
23286 &request,
23287 &[
23288 spawn_spec_capability,
23289 worktree_capability,
23290 profile_revision_capability,
23291 bundle_materialization_capability,
23292 history_context_pack_capability,
23293 ],
23294 ));
23295 }
23296
23297 #[tokio::test]
23298 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23299 let mut shared = terminal_test_shared();
23300 let spec = spawn_spec_fixture();
23301 let receipt = shared
23302 .resolve_spawn_spec(&spec)
23303 .unwrap()
23304 .receipt(shared.incarnation_id, terminal_address(1));
23305 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23306
23307 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23308 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23309 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23310
23311 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23312
23313 let mut revision_conflicting = spec.clone();
23314 revision_conflicting.expected_profile_revision =
23315 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23316 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23317 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23318
23319 let mut stale_cache_miss = spec.clone();
23320 stale_cache_miss.idempotency_key =
23321 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23322 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23323 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23324
23325 let mut conflicting = spec;
23326 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23327 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23328 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23329 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23330 assert!(shared.handle.snapshot().sessions.is_empty());
23331 }
23332
23333 #[tokio::test]
23334 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23335 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23336 let mut spec = spawn_spec_fixture();
23337 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23338 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23339 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23340
23341 let failure = shared
23342 .create_worktree(
23343 WorkspaceId::new("primary").unwrap(),
23344 WorkspaceId::new("new-worktree").unwrap(),
23345 "not-an-absolute-path".to_owned(),
23346 "invalid branch".to_owned(),
23347 None,
23348 )
23349 .await
23350 .unwrap_err();
23351 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23352
23353 let failure = shared
23354 .remove_worktree(
23355 WorkspaceId::new("primary").unwrap(),
23356 "not-an-absolute-path".to_owned(),
23357 )
23358 .await
23359 .unwrap_err();
23360 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23361 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23362 assert!(shared.handle.snapshot().sessions.is_empty());
23363 }
23364
23365 #[tokio::test]
23366 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23367 let root = temporary_workspace_root("manual-worktree-compensation");
23368 let source = root.join("source");
23369 let target = root.join("target");
23370 let invalid_state_parent = root.join("state-parent-is-a-file");
23371 let invalid_state_path = invalid_state_parent.join("state.json");
23372 std::fs::create_dir_all(&source).unwrap();
23373 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23374 run_test_git(&source, &["init"]);
23375 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23376 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23377 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23378 run_test_git(&source, &["add", "seed.txt"]);
23379 run_test_git(&source, &["commit", "-m", "seed"]);
23380
23381 let catalog = active_registry().unwrap();
23382 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23383 let workspace = WorkspaceConfig::new(
23384 WorkspaceId::new("primary").unwrap(),
23385 &source,
23386 ).unwrap();
23387 let mut shared = NodeShared::new(
23388 handle,
23389 "fixture-token".to_owned(),
23390 NodeId::new("node-worktree-compensation").unwrap(),
23391 vec![workspace],
23392 vec![agent("claude")],
23393 );
23394 shared.state_path = Some(invalid_state_path);
23395
23396 let error = shared.create_worktree(
23397 WorkspaceId::new("primary").unwrap(),
23398 WorkspaceId::new("compensated").unwrap(),
23399 target.to_string_lossy().into_owned(),
23400 "gate4agent/compensated".to_owned(),
23401 Some("HEAD".to_owned()),
23402 ).await.unwrap_err();
23403
23404 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23405 assert!(!target.exists());
23406 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23407 .iter()
23408 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23409 std::fs::remove_dir_all(root).unwrap();
23410 }
23411
23412 #[test]
23413 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23414 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23415 NativeGitWorktreeSnapshot {
23416 path: path.to_owned(),
23417 head: "0123456789abcdef".to_owned(),
23418 branch: Some("refs/heads/test".to_owned()),
23419 is_bare,
23420 is_main,
23421 locked: false,
23422 lock_reason: None,
23423 prunable: false,
23424 prunable_reason: None,
23425 workspace_id: None,
23426 }
23427 }
23428
23429 let source = r"C:\repo";
23430 let selected = r"C:\repo-worktree";
23431 let listed = vec![
23432 worktree(source, true, false),
23433 worktree(selected, false, false),
23434 ];
23435 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23436
23437 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23438 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23439
23440 let bare = vec![
23441 worktree(source, true, false),
23442 worktree(selected, false, true),
23443 ];
23444 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23445 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23446
23447 let foreign_failure =
23448 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23449 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23450 }
23451
23452 fn terminal_frame(sequence: u64) -> TerminalFrame {
23453 TerminalFrame {
23454 sequence,
23455 size: gate4agent_types::TerminalSize {
23456 rows: 24,
23457 columns: 80,
23458 },
23459 cursor_row: 2,
23460 cursor_column: 3,
23461 contents: format!("frame-{sequence}"),
23462 formatted: vec![1, 2, 3],
23463 scrollback_formatted: Vec::new(),
23464 alternate_screen: false,
23465 mouse_protocol_enabled: false,
23466 mouse_protocol_encoding: Default::default(),
23467 produced_at_unix_ms: 0,
23468 screen_state: gate4agent_types::PtyScreenState::default(),
23469 bracketed_paste: None,
23470 }
23471 }
23472
23473 #[test]
23474 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23475 let shared = terminal_test_shared();
23476 let mut receiver = shared.terminal_event_tx.subscribe();
23477 let original = terminal_address(1);
23478
23479 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23480 let first = receiver.try_recv().unwrap();
23481 assert_eq!(first.len(), 1);
23482 assert!(matches!(
23483 &first[0].event,
23484 NodeEvent::TerminalFrame { address, frame }
23485 if address == &original && frame.sequence == 7
23486 ));
23487
23488 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23489 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23490 assert!(matches!(
23491 receiver.try_recv(),
23492 Err(broadcast::error::TryRecvError::Empty)
23493 ));
23494
23495 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23496 let advanced = receiver.try_recv().unwrap();
23497 assert_eq!(advanced[0].sequence, 2);
23498
23499 let resumed = terminal_address(2);
23500 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23501 let reset = receiver.try_recv().unwrap();
23502 assert_eq!(reset[0].sequence, 3);
23503 assert!(matches!(
23504 &reset[0].event,
23505 NodeEvent::TerminalFrame { address, frame }
23506 if address == &resumed && frame.sequence == 1
23507 ));
23508 shared.clear_terminal_frame_watermark(&resumed);
23509 assert!(!shared
23510 .terminal_frame_watermarks
23511 .lock()
23512 .unwrap_or_else(|poisoned| poisoned.into_inner())
23513 .contains_key(&resumed.session.instance_id));
23514 }
23515
23516 #[test]
23517 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23518 let shared = terminal_test_shared();
23519 let mut receiver = shared.terminal_event_tx.subscribe();
23520 let address = terminal_address(1);
23521 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23522
23523 for sequence in 1..=count {
23524 shared.publish_terminal_frame_candidates(vec![(
23525 address.clone(),
23526 terminal_frame(sequence),
23527 )]);
23528 }
23529
23530 let history = shared
23531 .history
23532 .lock()
23533 .unwrap_or_else(|poisoned| poisoned.into_inner());
23534 assert!(history.events.is_empty());
23535 assert_eq!(history.last_sequence, count);
23536 assert_eq!(history.replay_floor_sequence, 1);
23537 drop(history);
23538 let NodeResponse::Resync {
23539 event_sequence,
23540 oldest_available_sequence,
23541 events,
23542 ..
23543 } = shared.resync(0) else {
23544 unreachable!("resync helper returned another response")
23545 };
23546 assert_eq!(event_sequence, count);
23547 assert_eq!(oldest_available_sequence, 1);
23548 assert!(events.is_empty());
23549 assert!(matches!(
23550 resync_required_event(&shared).event,
23551 NodeEvent::ResyncRequired {
23552 oldest_available_sequence: 1,
23553 }
23554 ));
23555 assert!(matches!(
23556 receiver.try_recv(),
23557 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23558 ));
23559 let latest = receiver.try_recv().unwrap();
23560 assert_eq!(latest.len(), 1);
23561 assert_eq!(latest[0].sequence, count);
23562 }
23563
23564 #[test]
23565 fn real_durable_eviction_advances_authoritative_replay_floor() {
23566 let shared = terminal_test_shared();
23567 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23568 shared.publish(NodeEvent::ControllerChanged { controller: None });
23569 }
23570
23571 let NodeResponse::Resync {
23572 event_sequence,
23573 oldest_available_sequence,
23574 events,
23575 ..
23576 } = shared.resync(0) else {
23577 unreachable!("resync helper returned another response")
23578 };
23579 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23580 assert_eq!(oldest_available_sequence, 2);
23581 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23582 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23583 assert!(matches!(
23584 resync_required_event(&shared).event,
23585 NodeEvent::ResyncRequired {
23586 oldest_available_sequence: 2,
23587 }
23588 ));
23589 }
23590
23591 #[test]
23592 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23593 let shared = terminal_test_shared();
23594 let without_capability = terminal_event_subscription(&shared, false);
23595 let with_capability = terminal_event_subscription(&shared, true);
23596 assert!(without_capability.is_none());
23597 assert!(with_capability.is_some());
23598 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23599 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23600 }));
23601 }
23602
23603 #[test]
23604 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23605 let mut pending = Vec::new();
23606 let mut event_sequence = 0_u64;
23607 for frame_sequence in 1..=4 {
23608 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23609 event_sequence += 1;
23610 let mut address = terminal_address(1);
23611 address.session.instance_id = AgentInstanceId(instance);
23612 queue_connection_event(
23613 &mut pending,
23614 NodeEventEnvelope {
23615 sequence: event_sequence,
23616 event: NodeEvent::TerminalFrame {
23617 address,
23618 frame: terminal_frame(frame_sequence),
23619 },
23620 },
23621 0,
23622 );
23623 }
23624 }
23625 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23626 pending.sort_unstable_by_key(|event| event.sequence);
23627
23628 let mut uninterrupted = pending.clone();
23629 let first_burst = connection_event_burst_len(uninterrupted.len());
23630 uninterrupted.drain(..first_burst);
23631 let second_burst = connection_event_burst_len(uninterrupted.len());
23632 uninterrupted.drain(..second_burst);
23633 assert!(uninterrupted.is_empty());
23634
23635 let (frame_tx, mut frame_rx) =
23636 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23637 frame_tx
23638 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23639 request_id: 77,
23640 request: NodeRequest::Snapshot,
23641 })))
23642 .unwrap();
23643 let burst_len = connection_event_burst_len(pending.len());
23644 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23645 pending.drain(..burst_len);
23646 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23647 assert!(matches!(
23648 frame_rx.try_recv(),
23649 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23650 request_id: 77,
23651 request: NodeRequest::Snapshot,
23652 })))
23653 ));
23654 }
23655
23656 #[test]
23657 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23658 let mut pending = Vec::new();
23659 for sequence in [7_u64, 8] {
23660 let mut address = terminal_address(1);
23661 address.session.instance_id = AgentInstanceId(sequence);
23662 queue_connection_event(
23663 &mut pending,
23664 NodeEventEnvelope {
23665 sequence,
23666 event: NodeEvent::TerminalFrame {
23667 address,
23668 frame: terminal_frame(sequence),
23669 },
23670 },
23671 0,
23672 );
23673 }
23674 let mut discard_through = 0;
23675 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23676 assert!(pending.is_empty());
23677
23678 let replayed = NodeEventEnvelope {
23679 sequence: 8,
23680 event: NodeEvent::TerminalFrame {
23681 address: terminal_address(1),
23682 frame: terminal_frame(8),
23683 },
23684 };
23685 let advanced = NodeEventEnvelope {
23686 sequence: 9,
23687 event: NodeEvent::TerminalFrame {
23688 address: terminal_address(2),
23689 frame: terminal_frame(1),
23690 },
23691 };
23692 queue_connection_event_batch(
23693 &mut pending,
23694 &[replayed, advanced.clone()],
23695 discard_through,
23696 );
23697 assert_eq!(pending, vec![advanced]);
23698 }
23699
23700 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23701 ManagedSessionRecord {
23702 record_id: SessionRecordId::new(record_id).unwrap(),
23703 display_name: format!("{provider} record"),
23704 provider: agent(provider),
23705 mode: SessionMode::Pty,
23706 state: ManagedSessionState::Unavailable,
23707 workspace_id: WorkspaceId::new("primary").unwrap(),
23708 canonical_root: opaque_windows_path(
23709 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23710 ),
23711 provider_session: None,
23712 active_session: None,
23713 environment_profile: None,
23714 bundle: None,
23715 context_id: None,
23716 context: None,
23717 exported_context: None,
23718 task_binding: None,
23719 created_at_unix_ms: 1,
23720 updated_at_unix_ms: 1,
23721 last_error: None,
23722 }
23723 }
23724
23725 fn retention_test_record(
23726 workspace_id: &str,
23727 record_id: &str,
23728 state: ManagedSessionState,
23729 updated_at_unix_ms: u64,
23730 ) -> ManagedSessionRecord {
23731 ManagedSessionRecord {
23732 record_id: SessionRecordId::new(record_id).unwrap(),
23733 display_name: format!("{record_id} display"),
23734 provider: agent("claude"),
23735 mode: SessionMode::Pty,
23736 state,
23737 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23738 canonical_root: opaque_windows_path(
23739 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23740 ),
23741 provider_session: None,
23742 active_session: None,
23743 environment_profile: None,
23744 bundle: None,
23745 context_id: None,
23746 context: None,
23747 exported_context: None,
23748 task_binding: None,
23749 created_at_unix_ms: updated_at_unix_ms,
23750 updated_at_unix_ms,
23751 last_error: None,
23752 }
23753 }
23754
23755 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23763 vec![
23764 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23765 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23766 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23767 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23768 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23769 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23770 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23771 retention_test_record(
23772 "workspace-a",
23773 "decoy-pending",
23774 ManagedSessionState::IdentityPending,
23775 now - 500_000,
23776 ),
23777 retention_test_record(
23778 "workspace-a",
23779 "decoy-dormant",
23780 ManagedSessionState::Dormant,
23781 now - 500_000,
23782 ),
23783 ]
23784 }
23785
23786 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23787 let root = temporary_workspace_root("session-record-retention");
23788 std::fs::create_dir_all(&root).unwrap();
23789 let catalog = active_registry().unwrap();
23790 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23791 let workspace_a =
23792 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23793 let workspace_b =
23794 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23795 let mut shared = NodeShared::new(
23796 handle,
23797 "fixture-token".to_owned(),
23798 NodeId::new("node-retention-test").unwrap(),
23799 vec![workspace_a, workspace_b],
23800 vec![agent("claude")],
23801 );
23802 shared.state_path = Some(root.join("node-state-v10.json"));
23803 let now = unix_time_ms();
23804 for record in retention_fixture_records(now) {
23805 shared.insert_record(record).unwrap();
23806 }
23807 shared
23808 .persist_state_locked()
23809 .expect("fixture-only, empty-history persist against a fresh temp state path");
23810 (shared, runtime, root, now)
23811 }
23812
23813 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23814 shared
23815 .history
23816 .lock()
23817 .unwrap_or_else(|poisoned| poisoned.into_inner())
23818 .events
23819 .iter()
23820 .filter_map(|envelope| match &envelope.event {
23821 NodeEvent::SessionRecordRemoved { record_id } => {
23822 Some(record_id.as_str().to_owned())
23823 }
23824 _ => None,
23825 })
23826 .collect()
23827 }
23828
23829 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23830 shared
23831 .session_records
23832 .lock()
23833 .unwrap_or_else(|poisoned| poisoned.into_inner())
23834 .records
23835 .keys()
23836 .map(|record_id| record_id.as_str().to_owned())
23837 .collect()
23838 }
23839
23840 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23841 values.iter().map(|value| (*value).to_owned()).collect()
23842 }
23843
23844 #[tokio::test]
23845 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23846 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23847 shared.session_record_retention = SessionRecordRetentionConfig {
23848 age_ms: 0,
23849 keep_per_workspace: 1,
23850 };
23851 shared.retire_unavailable_session_records().await;
23852
23853 assert_eq!(
23854 retention_remaining_record_ids(&shared),
23855 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23856 );
23857 assert_eq!(
23858 retention_removed_record_ids(&shared),
23859 owned(&["a1", "a2", "b1", "b2"]),
23860 );
23861 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23862
23863 drop(shared);
23864 drop(runtime);
23865 std::fs::remove_dir_all(root).unwrap();
23866 }
23867
23868 #[tokio::test]
23874 async fn session_record_retention_both_set_retires_only_the_intersection() {
23875 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23876 shared.session_record_retention = SessionRecordRetentionConfig {
23877 age_ms: 250_000,
23878 keep_per_workspace: 1,
23879 };
23880 shared.retire_unavailable_session_records().await;
23881
23882 assert_eq!(
23883 retention_remaining_record_ids(&shared),
23884 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23885 );
23886 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23887 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23888
23889 drop(shared);
23890 drop(runtime);
23891 std::fs::remove_dir_all(root).unwrap();
23892 }
23893
23894 #[tokio::test]
23895 async fn session_record_retention_both_disabled_is_a_no_op() {
23896 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23897 shared.session_record_retention = SessionRecordRetentionConfig::default();
23898 shared.retire_unavailable_session_records().await;
23899
23900 assert_eq!(
23901 retention_remaining_record_ids(&shared),
23902 owned(&[
23903 "a1", "a2", "a3", "b1", "b2", "b3",
23904 "decoy-live", "decoy-pending", "decoy-dormant",
23905 ]),
23906 );
23907 assert!(retention_removed_record_ids(&shared).is_empty());
23908 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23909
23910 drop(shared);
23911 drop(runtime);
23912 std::fs::remove_dir_all(root).unwrap();
23913 }
23914
23915 #[test]
23916 fn session_record_context_export_selects_one_exact_history_identity() {
23917 let candidates = vec![
23918 HistoryCandidateSummary {
23919 id: "candidate-other".to_owned(),
23920 session_id_hint: "provider-other".to_owned(),
23921 modified_at_unix_ms: Some(1),
23922 },
23923 HistoryCandidateSummary {
23924 id: "candidate-exact".to_owned(),
23925 session_id_hint: "provider-exact".to_owned(),
23926 modified_at_unix_ms: Some(2),
23927 },
23928 ];
23929 assert_eq!(
23930 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23931 "candidate-exact",
23932 );
23933 }
23934
23935 #[test]
23936 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23937 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23938 id: id.to_owned(),
23939 session_id_hint: hint.to_owned(),
23940 modified_at_unix_ms: None,
23941 };
23942 let missing = select_history_candidate(
23943 &[candidate("candidate-other", "provider-other")],
23944 Some("provider-exact"),
23945 )
23946 .unwrap_err();
23947 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23948 let ambiguous = select_history_candidate(
23949 &[
23950 candidate("candidate-a", "provider-exact"),
23951 candidate("candidate-b", "provider-exact"),
23952 ],
23953 Some("provider-exact"),
23954 )
23955 .unwrap_err();
23956 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23957 }
23958
23959 #[test]
23967 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23968 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23969 id: id.to_owned(),
23970 session_id_hint: hint.to_owned(),
23971 modified_at_unix_ms: None,
23972 };
23973 assert_eq!(
23974 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23975 .unwrap(),
23976 "candidate-sole",
23977 );
23978 let missing = select_history_candidate(&[], None).unwrap_err();
23979 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23980 let ambiguous = select_history_candidate(
23981 &[
23982 candidate("candidate-a", "sidecar-session-a"),
23983 candidate("candidate-b", "sidecar-session-b"),
23984 ],
23985 None,
23986 )
23987 .unwrap_err();
23988 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23989 }
23990
23991 #[test]
23992 fn session_record_context_export_revalidation_rejects_record_change() {
23993 let session = terminal_address(1);
23994 let identity = ProviderSessionIdentity {
23995 key: ProviderSessionKey::SessionId,
23996 id: "provider-exact".to_owned(),
23997 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23998 };
23999 let mut expected = record("claude", "record-context-export");
24000 expected.state = ManagedSessionState::Live;
24001 expected.provider_session = Some(identity.clone());
24002 expected.active_session = Some(session.clone());
24003 let binding = SessionBinding {
24004 workspace_id: session.workspace_id.clone(),
24005 generation: session.session.generation,
24006 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24007 pending_resume: None,
24008 record_id: Some(expected.record_id.clone()),
24009 managed_worktree_lease_id: None,
24010 environment_profile: None,
24011 bundle: None,
24012 context: None,
24013 materialization_id: None,
24014 };
24015 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24016 assert!(session_record_context_export_binding_is_exact(
24017 &expected,
24018 &expected,
24019 Some(&identity),
24020 &session,
24021 &binding,
24022 &expected.provider,
24023 ¤t_root,
24024 false,
24025 ));
24026 let mut changed = expected.clone();
24027 changed.updated_at_unix_ms += 1;
24028 assert!(!session_record_context_export_binding_is_exact(
24029 &expected,
24030 &changed,
24031 Some(&identity),
24032 &session,
24033 &binding,
24034 &expected.provider,
24035 ¤t_root,
24036 false,
24037 ));
24038 assert!(!session_record_context_export_binding_is_exact(
24043 &expected,
24044 &changed,
24045 Some(&identity),
24046 &session,
24047 &binding,
24048 &expected.provider,
24049 ¤t_root,
24050 true,
24051 ));
24052 let mut detached = expected.clone();
24053 detached.state = ManagedSessionState::Dormant;
24054 detached.active_session = None;
24055 detached.updated_at_unix_ms += 1;
24056 assert!(!session_record_context_export_binding_is_exact(
24057 &expected,
24058 &detached,
24059 Some(&identity),
24060 &session,
24061 &binding,
24062 &expected.provider,
24063 ¤t_root,
24064 false,
24065 ));
24066 assert!(session_record_context_export_binding_is_exact(
24067 &expected,
24068 &detached,
24069 Some(&identity),
24070 &session,
24071 &binding,
24072 &expected.provider,
24073 ¤t_root,
24074 true,
24075 ));
24076 }
24077
24078 #[test]
24085 fn session_record_context_export_binding_is_exact_without_identity() {
24086 let session = terminal_address(1);
24087 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24088 expected.state = ManagedSessionState::Live;
24089 expected.active_session = Some(session.clone());
24090 let binding = SessionBinding {
24091 workspace_id: session.workspace_id.clone(),
24092 generation: session.session.generation,
24093 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24094 pending_resume: None,
24095 record_id: Some(expected.record_id.clone()),
24096 managed_worktree_lease_id: None,
24097 environment_profile: None,
24098 bundle: None,
24099 context: None,
24100 materialization_id: None,
24101 };
24102 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24103 assert!(session_record_context_export_binding_is_exact(
24104 &expected,
24105 &expected,
24106 None,
24107 &session,
24108 &binding,
24109 &expected.provider,
24110 ¤t_root,
24111 false,
24112 ));
24113 let mut drifted = expected.clone();
24117 drifted.provider_session = Some(ProviderSessionIdentity {
24118 key: ProviderSessionKey::SessionId,
24119 id: "unexpected-identity".to_owned(),
24120 transcript_path: None,
24121 });
24122 assert!(!session_record_context_export_binding_is_exact(
24123 &expected,
24124 &drifted,
24125 None,
24126 &session,
24127 &binding,
24128 &expected.provider,
24129 ¤t_root,
24130 false,
24131 ));
24132 }
24133
24134 #[test]
24135 fn session_record_context_export_preflight_requires_live_usable_source() {
24136 let mut source = record("claude", "record-context-export-preflight");
24137 source.state = ManagedSessionState::Live;
24138 assert!(session_record_context_export_source_is_usable(
24139 &source,
24140 &SessionStatus::Running,
24141 false,
24142 ));
24143 assert!(session_record_context_export_source_is_usable(
24144 &source,
24145 &SessionStatus::Exited { exit_code: Some(0) },
24146 false,
24147 ));
24148
24149 source.state = ManagedSessionState::Dormant;
24150 assert!(!session_record_context_export_source_is_usable(
24151 &source,
24152 &SessionStatus::Running,
24153 false,
24154 ));
24155 assert!(session_record_context_export_source_is_usable(
24159 &source,
24160 &SessionStatus::Exited { exit_code: Some(0) },
24161 true,
24162 ));
24163 assert!(!session_record_context_export_source_is_usable(
24164 &source,
24165 &SessionStatus::Exited { exit_code: Some(0) },
24166 false,
24167 ));
24168 source.state = ManagedSessionState::Live;
24169 assert!(!session_record_context_export_source_is_usable(
24170 &source,
24171 &SessionStatus::Starting,
24172 false,
24173 ));
24174 }
24175
24176 #[test]
24177 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24178 let catalog = active_registry().unwrap();
24179 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24180 let workspace = WorkspaceConfig::new(
24181 WorkspaceId::new("primary").unwrap(),
24182 std::env::current_dir().unwrap(),
24183 )
24184 .unwrap();
24185 let shared = NodeShared::new(
24186 handle,
24187 "fixture-token".to_owned(),
24188 NodeId::new("node-context-export-conflict").unwrap(),
24189 vec![workspace],
24190 vec![agent("claude")],
24191 );
24192 let history = HistorySessionRecord {
24193 session_id: "provider-exact".to_owned(),
24194 title: None,
24195 cwd: None,
24196 model: None,
24197 message_count: 1,
24198 completed_turn_count: None,
24199 total_tokens: 0,
24200 messages: vec![gate4agent_types::HistoryMessageRecord {
24201 role: gate4agent_types::HistoryMessageRole::User,
24202 text: "bounded context".to_owned(),
24203 }],
24204 };
24205 let pack = NodeContextPack::export(
24206 ContextPackLineageReceipt {
24207 source_node_id: shared.node_id.clone(),
24208 source_session: terminal_address(1),
24209 source_provider: agent("claude"),
24210 },
24211 &history,
24212 )
24213 .unwrap();
24214 let context_id = pack.receipt().id.clone();
24215 let session = terminal_address(1);
24216 let identity = ProviderSessionIdentity {
24217 key: ProviderSessionKey::SessionId,
24218 id: history.session_id.clone(),
24219 transcript_path: None,
24220 };
24221 let mut expected = record("claude", "record-context-export-final-conflict");
24222 expected.state = ManagedSessionState::Live;
24223 expected.provider_session = Some(identity.clone());
24224 expected.active_session = Some(session.clone());
24225 let mut changed = expected.clone();
24226 changed.updated_at_unix_ms += 1;
24227 shared.insert_record(changed).unwrap();
24228
24229 let error = shared
24230 .commit_context_pack_for_session_record(
24231 &expected,
24232 Some(&identity),
24233 &session,
24234 "candidate-exact",
24235 &history,
24236 pack,
24237 false,
24238 )
24239 .unwrap_err();
24240
24241 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24242 assert!(shared
24243 .context_catalog
24244 .read()
24245 .unwrap_or_else(|poisoned| poisoned.into_inner())
24246 .get(&context_id)
24247 .is_none());
24248 }
24249
24250 #[tokio::test]
24251 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24252 let root = temporary_workspace_root("session-task-binding");
24253 std::fs::create_dir_all(&root).unwrap();
24254 let state_path = root.join("node-state.json");
24255 let catalog = active_registry().unwrap();
24256 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24257 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24258 let node_id = NodeId::new("node-1").unwrap();
24259 let mut shared = NodeShared::new(
24260 handle,
24261 "fixture-token".to_owned(),
24262 node_id.clone(),
24263 vec![workspace],
24264 vec![agent("claude")],
24265 );
24266 shared.state_path = Some(state_path.clone());
24267 let record = record("claude", "sr-task");
24268 let record_id = record.record_id.clone();
24269 shared.insert_record(record).unwrap();
24270 shared.persist_state().unwrap();
24271 shared
24272 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24273 .unwrap();
24274
24275 let minted = process_request_inner(
24276 &shared,
24277 77,
24278 ClientRole::Operator,
24279 NodeRequest::SetSessionTask {
24280 record_id: record_id.clone(),
24281 expected_revision: 0,
24282 target: SessionTaskTargetV1::New,
24283 },
24284 )
24285 .await
24286 .unwrap();
24287 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24288 panic!("session task mutation returned another response");
24289 };
24290 let minted_binding = minted.task_binding.clone().unwrap();
24291 let minted_task_id = minted_binding.task_id.clone().unwrap();
24292 assert_eq!(minted_binding.revision, 1);
24293 assert!(minted_task_id.as_str().starts_with("task-"));
24294 assert_eq!(minted_task_id.as_str().len(), 29);
24295
24296 let event_count = shared.current_sequence();
24297 let repeated = process_request_inner(
24298 &shared,
24299 77,
24300 ClientRole::Operator,
24301 NodeRequest::SetSessionTask {
24302 record_id: record_id.clone(),
24303 expected_revision: 1,
24304 target: SessionTaskTargetV1::Existing {
24305 task_id: minted_task_id.clone(),
24306 },
24307 },
24308 )
24309 .await
24310 .unwrap();
24311 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24312 assert_eq!(shared.current_sequence(), event_count);
24313
24314 let stale = process_request_inner(
24315 &shared,
24316 77,
24317 ClientRole::Operator,
24318 NodeRequest::SetSessionTask {
24319 record_id: record_id.clone(),
24320 expected_revision: 0,
24321 target: SessionTaskTargetV1::Existing {
24322 task_id: minted_task_id,
24323 },
24324 },
24325 )
24326 .await
24327 .unwrap_err();
24328 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24329
24330 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24331 let assigned = process_request_inner(
24332 &shared,
24333 77,
24334 ClientRole::Operator,
24335 NodeRequest::SetSessionTask {
24336 record_id: record_id.clone(),
24337 expected_revision: 1,
24338 target: SessionTaskTargetV1::Existing {
24339 task_id: assigned_id.clone(),
24340 },
24341 },
24342 )
24343 .await
24344 .unwrap();
24345 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24346 panic!("session task assignment returned another response");
24347 };
24348 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24349 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24350
24351 let cleared = process_request_inner(
24352 &shared,
24353 77,
24354 ClientRole::Operator,
24355 NodeRequest::SetSessionTask {
24356 record_id: record_id.clone(),
24357 expected_revision: 2,
24358 target: SessionTaskTargetV1::Clear,
24359 },
24360 )
24361 .await
24362 .unwrap();
24363 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24364 panic!("session task clear returned another response");
24365 };
24366 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24367 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24368 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24369 assert_eq!(
24370 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24371 cleared.updated_at_unix_ms,
24372 );
24373
24374 let persisted = std::fs::read(&state_path).unwrap();
24375 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24376 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24377 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24378 std::fs::remove_dir_all(root).unwrap();
24379 }
24380
24381 #[test]
24382 fn session_task_capability_strips_snapshots_events_and_replies() {
24383 let mut bound = record("claude", "sr-task-projection");
24384 bound.updated_at_unix_ms = 2;
24385 bound.task_binding = Some(SessionTaskBindingV1 {
24386 revision: 1,
24387 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24388 changed_at_unix_ms: 2,
24389 });
24390 let request = NodeRequest::SetSessionTask {
24391 record_id: bound.record_id.clone(),
24392 expected_revision: 1,
24393 target: SessionTaskTargetV1::Clear,
24394 };
24395 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24396 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24397 }));
24398 assert!(!request_uses_unnegotiated_capability(
24399 &request,
24400 &baseline_capabilities().unwrap(),
24401 ));
24402 assert!(request_uses_unnegotiated_capability(&request, &[]));
24403
24404 let mut snapshot = terminal_test_shared().snapshot();
24405 snapshot.session_records.push(bound.clone());
24406 let mut reply = ResponseEnvelope {
24407 request_id: 1,
24408 result: Ok(NodeResponse::Resync {
24409 event_sequence: 1,
24410 oldest_available_sequence: 1,
24411 snapshot,
24412 events: vec![NodeEventEnvelope {
24413 sequence: 1,
24414 event: NodeEvent::SessionRecordUpserted {
24415 record: bound.clone(),
24416 },
24417 }],
24418 }),
24419 };
24420 project_response_without_session_task_binding(&mut reply);
24421 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24422 panic!("task projection changed the response kind");
24423 };
24424 assert!(snapshot.session_records[0].task_binding.is_none());
24425 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24426 panic!("task projection changed the event kind");
24427 };
24428 assert!(record.task_binding.is_none());
24429
24430 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24431 sequence: 2,
24432 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24433 });
24434 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24435 panic!("task event projection changed the event kind");
24436 };
24437 assert!(record.task_binding.is_none());
24438
24439 let mut direct = ResponseEnvelope {
24440 request_id: 2,
24441 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24442 };
24443 project_response_without_session_task_binding(&mut direct);
24444 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24445 panic!("task reply projection changed the response kind");
24446 };
24447 assert!(record.task_binding.is_none());
24448 }
24449
24450 #[test]
24451 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24452 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24453 let workspace = WorkspaceConfig::new(
24454 WorkspaceId::new("primary").unwrap(),
24455 std::env::current_dir().unwrap(),
24456 )
24457 .unwrap();
24458 let config = NodeServerConfig::new(
24459 DEFAULT_NODE_ENDPOINT,
24460 "fixture-token",
24461 NodeId::new("node-1").unwrap(),
24462 [workspace],
24463 )
24464 .unwrap();
24465 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24466 }
24467
24468 #[cfg(all(feature = "fixture", windows))]
24469 #[test]
24470 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24471 let root = temporary_workspace_root("provider-bundle-argv-hold");
24472 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24473 std::fs::create_dir_all(&root).unwrap();
24474 std::fs::create_dir_all(&outside).unwrap();
24475 let proof_path = root.join("provider.proof");
24476 let release_signal = root.join("release.signal");
24477 let config = NodeServerConfig::new(
24478 format!(
24479 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24480 std::process::id(),
24481 ),
24482 "fixture-token",
24483 NodeId::new("provider-bundle-hold-node").unwrap(),
24484 [WorkspaceConfig::new(
24485 WorkspaceId::new("primary").unwrap(),
24486 std::env::current_dir().unwrap(),
24487 )
24488 .unwrap()],
24489 )
24490 .unwrap()
24491 .with_spawn_profiles(SpawnProfileRegistry::new([
24492 crate::protocol::SpawnProfileDefaults {
24493 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24494 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24495 .unwrap(),
24496 provider: agent("kimi"),
24497 mode: SessionMode::Pty,
24498 terminal_size: gate4agent_types::TerminalSize {
24499 rows: 24,
24500 columns: 80,
24501 },
24502 prompt: None,
24503 bundle_id: None,
24504 context_id: None,
24505 environment_profile_id: None,
24506 },
24507 ])
24508 .unwrap());
24509 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24510 config,
24511 agent("kimi"),
24512 proof_path.clone(),
24513 release_signal.clone(),
24514 )
24515 .unwrap();
24516 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24517
24518 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24519 agent("kimi"),
24520 proof_path.clone(),
24521 Some(release_signal.clone()),
24522 )
24523 .unwrap();
24524 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24525 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24526 let release_wait = wrapper
24527 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24528 .unwrap();
24529 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24530 assert!(proof_write < release_wait);
24531 assert!(release_wait < fixture_ready);
24532 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24533 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24534 assert_eq!(
24535 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24536 &[
24537 proof_path.to_string_lossy().into_owned(),
24538 release_signal.to_string_lossy().into_owned(),
24539 ],
24540 );
24541
24542 let error = NodeServer::provider_bundle_argv_fixture_spec(
24543 agent("kimi"),
24544 proof_path,
24545 Some(outside.join("release.signal")),
24546 )
24547 .unwrap_err();
24548 assert!(matches!(
24549 error,
24550 NodeServerError::Registry(message)
24551 if message == "provider bundle argv release path must share the proof parent"
24552 ));
24553 drop(server);
24554 std::fs::remove_dir_all(root).unwrap();
24555 std::fs::remove_dir_all(outside).unwrap();
24556 }
24557
24558 #[cfg(feature = "fixture")]
24559 #[test]
24560 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24561 let root = temporary_workspace_root("context-fixture-registry");
24562 std::fs::create_dir_all(&root).unwrap();
24563 let proof_path = root.join("context.proof");
24564 let config = NodeServerConfig::new(
24565 format!(
24566 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24567 std::process::id(),
24568 ),
24569 "fixture-token",
24570 NodeId::new("context-fixture-node").unwrap(),
24571 [WorkspaceConfig::new(
24572 WorkspaceId::new("primary").unwrap(),
24573 std::env::current_dir().unwrap(),
24574 )
24575 .unwrap()],
24576 )
24577 .unwrap();
24578 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24579 let mut enabled = server
24580 .shared
24581 .enabled_providers
24582 .iter()
24583 .map(AgentId::as_str)
24584 .collect::<Vec<_>>();
24585 enabled.sort_unstable();
24586 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24587 for provider in &server.shared.enabled_providers {
24588 assert!(server
24589 .shared
24590 .provider_adapter_contracts
24591 .iter()
24592 .any(|contract| {
24593 &contract.provider == provider
24594 && contract.family == AdapterFamily::History
24595 }));
24596 }
24597 drop(server);
24598 std::fs::remove_dir_all(root).unwrap();
24599 }
24600
24601 #[cfg(feature = "fixture")]
24602 #[test]
24603 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24604 let root = temporary_workspace_root("monitoring-context-fixture");
24605 std::fs::create_dir_all(&root).unwrap();
24606 let proof_path = root.join("context.proof");
24607 let standard = NodeServer::context_pack_fixture_catalog(
24608 proof_path.clone(),
24609 None,
24610 false,
24611 ).unwrap();
24612 let monitoring = NodeServer::context_pack_fixture_catalog(
24613 proof_path.clone(),
24614 None,
24615 true,
24616 ).unwrap();
24617 let claude = AgentId::new("claude").unwrap();
24618 let standard_claude = standard.get(&claude).unwrap();
24619 let monitoring_claude = monitoring.get(&claude).unwrap();
24620 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24624 assert_eq!(
24625 monitoring_claude.capabilities.adapters.history,
24626 standard_claude.capabilities.adapters.history,
24627 );
24628 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24629 let codex = AgentId::new("codex").unwrap();
24630 let standard_codex = standard.get(&codex).unwrap();
24631 let monitoring_codex = monitoring.get(&codex).unwrap();
24632 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24633 assert_eq!(
24634 monitoring_codex.capabilities.adapters.history,
24635 standard_codex.capabilities.adapters.history,
24636 );
24637 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24638 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24639 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24640 .unwrap();
24641 for required in [
24642 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24643 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24644 "@('role', 'text')",
24645 "g4a-private-provider-session-canary",
24646 "private-provider-login-identity",
24647 "$rawProof",
24648 "$env:GATE4AGENT_HOOK_URL",
24649 "g4a-private-provider-session-canary",
24650 ] {
24651 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24652 }
24653 for provider in ["grok", "kimi"] {
24654 let provider = AgentId::new(provider).unwrap();
24655 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24656 }
24657
24658 let config = NodeServerConfig::new(
24659 format!(
24660 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24661 std::process::id(),
24662 ),
24663 "fixture-token",
24664 NodeId::new("monitoring-context-fixture-node").unwrap(),
24665 [WorkspaceConfig::new(
24666 WorkspaceId::new("primary").unwrap(),
24667 std::env::current_dir().unwrap(),
24668 )
24669 .unwrap()],
24670 )
24671 .unwrap();
24672 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24673 assert!(server.shared.fixture_semantic_hook_policy);
24674 drop(server);
24675 std::fs::remove_dir_all(root).unwrap();
24676 }
24677
24678 #[cfg(feature = "fixture")]
24679 #[test]
24680 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24681 let root = temporary_workspace_root("kimi-context-only-fixture");
24682 std::fs::create_dir_all(&root).unwrap();
24683 let proof_path = root.join("context.proof");
24684 let standard = NodeServer::context_pack_fixture_catalog(
24685 proof_path.clone(),
24686 None,
24687 false,
24688 ).unwrap();
24689 let kimi = AgentId::new("kimi").unwrap();
24690 let context_only = NodeServer::context_pack_fixture_catalog(
24691 proof_path,
24692 Some(&kimi),
24693 false,
24694 )
24695 .unwrap();
24696
24697 let codex = AgentId::new("codex").unwrap();
24698 assert_eq!(
24699 standard.get(&codex).unwrap().launch.fixed_args,
24700 context_only.get(&codex).unwrap().launch.fixed_args,
24701 );
24702 let script = context_only
24703 .get(&kimi)
24704 .unwrap()
24705 .launch
24706 .fixed_args
24707 .iter()
24708 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24709 .expect("Kimi context-only validation script is missing");
24710 for required in [
24711 "$bundleArgs.Count -ne 0",
24712 "GATE4AGENT_CONTEXT_ROOT",
24713 "context-pack.json",
24714 "$entries.Count -ne 1",
24715 "g4a-context-pack-v1",
24716 "$document.PSObject.Properties['cwd']",
24717 "'claude', 'codex', 'grok', 'kimi'",
24718 "$hasUser",
24719 "$hasAssistant",
24720 "SHA256",
24721 "context-only",
24722 ] {
24723 assert!(script.contains(required), "missing validation: {required}");
24724 }
24725 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24726 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24727 }
24728 assert!(!standard
24729 .get(&kimi)
24730 .unwrap()
24731 .launch
24732 .fixed_args
24733 .iter()
24734 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24735
24736 std::fs::remove_dir_all(root).unwrap();
24737 }
24738
24739 #[cfg(all(feature = "fixture", windows))]
24740 #[tokio::test]
24741 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24742 let root = temporary_workspace_root("kimi-context-only-spawn");
24743 let workspace_root = root.join("workspace");
24744 std::fs::create_dir_all(&workspace_root).unwrap();
24745 let materialization_root = root.join("private-materializations");
24746 let proof_path = root.join("context.proof");
24747 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24748 let workspace_id = WorkspaceId::new("primary").unwrap();
24749 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24750 let endpoint = format!(
24751 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24752 std::process::id(),
24753 unix_time_ms(),
24754 );
24755 let spawn_profiles = SpawnProfileRegistry::new([
24756 crate::protocol::SpawnProfileDefaults {
24757 profile_id: profile_id.clone(),
24758 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24759 provider: agent("kimi"),
24760 mode: SessionMode::Pty,
24761 terminal_size: gate4agent_types::TerminalSize {
24762 rows: 30,
24763 columns: 120,
24764 },
24765 prompt: None,
24766 bundle_id: None,
24767 context_id: None,
24768 environment_profile_id: None,
24769 },
24770 ])
24771 .unwrap();
24772 let mut config = NodeServerConfig::new(
24773 &endpoint,
24774 "fixture-token",
24775 node_id.clone(),
24776 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24777 )
24778 .unwrap()
24779 .with_spawn_profiles(spawn_profiles)
24780 .with_session_environment_materialization(
24781 materialization_root,
24782 Arc::new(FixtureSecretResolver {
24783 deny: Arc::new(AtomicBool::new(false)),
24784 }),
24785 )
24786 .unwrap();
24787 config.fixture_raw_pty_runtime = true;
24788 let server = NodeServer::new_context_only_proof_fixture(
24789 config,
24790 agent("kimi"),
24791 proof_path.clone(),
24792 )
24793 .unwrap();
24794 let shared = Arc::clone(&server.shared);
24795 let shutdown = server.shutdown_handle();
24796
24797 let history = HistorySessionRecord {
24798 session_id: "context-only-source-history".to_owned(),
24799 title: None,
24800 cwd: None,
24801 model: None,
24802 message_count: 2,
24803 completed_turn_count: None,
24804 total_tokens: 0,
24805 messages: vec![
24806 gate4agent_types::HistoryMessageRecord {
24807 role: gate4agent_types::HistoryMessageRole::User,
24808 text: "continue the bounded parent run through native C2".to_owned(),
24809 },
24810 gate4agent_types::HistoryMessageRecord {
24811 role: gate4agent_types::HistoryMessageRole::Assistant,
24812 text: "the exact parent context is ready for the child run".to_owned(),
24813 },
24814 ],
24815 };
24816 let pack = NodeContextPack::export(
24817 ContextPackLineageReceipt {
24818 source_node_id: node_id.clone(),
24819 source_session: SessionAddress {
24820 workspace_id: workspace_id.clone(),
24821 session: SessionKey {
24822 instance_id: AgentInstanceId(71),
24823 generation: SessionGeneration(1),
24824 },
24825 },
24826 source_provider: agent("codex"),
24827 },
24828 &history,
24829 )
24830 .unwrap();
24831 let expected_context_hash = digest(&SHA256, pack.bytes())
24832 .as_ref()
24833 .iter()
24834 .map(|byte| format!("{byte:02x}"))
24835 .collect::<String>();
24836 let context = shared
24837 .context_catalog
24838 .write()
24839 .unwrap_or_else(|poisoned| poisoned.into_inner())
24840 .insert(pack)
24841 .unwrap();
24842 let spec = SpawnSpec {
24843 target: crate::protocol::SpawnTarget {
24844 node_id,
24845 workspace_id: workspace_id.clone(),
24846 worktree_id: None,
24847 },
24848 profile_id,
24849 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24850 "target-kimi-r1",
24851 ).unwrap(),
24852 overrides: crate::protocol::SpawnOverrides {
24853 context_id: crate::protocol::SpawnOverride::Set {
24854 value: context.id.clone(),
24855 },
24856 ..crate::protocol::SpawnOverrides::default()
24857 },
24858 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24859 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24860 required_capabilities: SpawnRequiredCapabilities::default(),
24861 };
24862 let resolved = match shared.resolve_spawn_spec(&spec) {
24863 Ok(resolved) => resolved,
24864 Err(error) => panic!(
24865 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24866 error.code,
24867 error.message,
24868 ),
24869 };
24870 let resolved_context = match shared.resolve_context(&resolved) {
24871 Ok(Some(resolved_context)) => resolved_context,
24872 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24873 Err(error) => panic!(
24874 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24875 error.code,
24876 error.message,
24877 ),
24878 };
24879 assert_eq!(resolved_context, context);
24880 let runtime_policy = match shared
24881 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24882 .await
24883 {
24884 Ok(runtime_policy) => runtime_policy,
24885 Err(error) => panic!(
24886 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24887 error.code,
24888 error.message,
24889 ),
24890 };
24891 assert!(runtime_policy.raw_pty_lifecycle);
24892
24893 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24894 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24895 agent("kimi"),
24896 TransportKind::Pty,
24897 vec![EnvMutation {
24898 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24899 value: Some(OsString::from("must-remain-rejected")),
24900 }],
24901 )
24902 .unwrap();
24903 assert_eq!(
24904 launch_control
24905 .install_native_launch_environment_overlay(
24906 AgentInstanceId(72),
24907 generic_overlay,
24908 )
24909 .unwrap_err(),
24910 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24911 );
24912
24913 let diagnostic_address = SessionAddress {
24914 workspace_id: workspace_id.clone(),
24915 session: SessionKey {
24916 instance_id: AgentInstanceId(73),
24917 generation: SessionGeneration(1),
24918 },
24919 };
24920 let (diagnostic_overlay, diagnostic_materialization) = match shared
24921 .prepare_session_materialization(
24922 &diagnostic_address,
24923 &agent("kimi"),
24924 SessionMode::Pty,
24925 None,
24926 None,
24927 Some(&resolved_context),
24928 None,
24929 )
24930 {
24931 Ok(Some(prepared)) => prepared,
24932 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24933 Err(error) => panic!(
24934 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24935 error.code,
24936 error.message,
24937 ),
24938 };
24939 let diagnostic_overlay = diagnostic_overlay
24940 .expect("Kimi context-only materialization omitted its environment overlay");
24941 assert!(matches!(
24942 &diagnostic_overlay,
24943 PreparedNativeLaunchOverlay::Environment(_),
24944 ));
24945 match shared.install_prepared_launch_overlay(
24946 diagnostic_address.session.instance_id,
24947 diagnostic_overlay,
24948 ) {
24949 Ok(None) => {}
24950 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24951 Err(error) => panic!(
24952 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24953 error.code,
24954 error.message,
24955 ),
24956 }
24957 assert!(launch_control.clear_native_instance_launch_overlay(
24958 diagnostic_address.session.instance_id,
24959 ));
24960 drop(diagnostic_materialization);
24961 assert!(shared
24962 .materializations
24963 .lock()
24964 .unwrap_or_else(|poisoned| poisoned.into_inner())
24965 .is_empty());
24966
24967 let node_task = tokio::spawn(server.run());
24968 let ready_client = timeout(Duration::from_secs(5), async {
24969 loop {
24970 match gate4agent_node_wire::LocalNodeClient::connect(
24971 &endpoint,
24972 &shared.node_id,
24973 ClientRole::Observer,
24974 "fixture-token",
24975 )
24976 .await
24977 {
24978 Ok(client) => break client,
24979 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24980 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24981 }
24982 }
24983 })
24984 .await
24985 .expect("Kimi context-only Node did not become ready");
24986 assert_eq!(
24987 ready_client
24988 .hello()
24989 .snapshot
24990 .provider_runtime_statuses
24991 .iter()
24992 .find(|status| status.provider().as_str() == "kimi")
24993 .expect("Kimi runtime status is missing")
24994 .mode(),
24995 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24996 );
24997 drop(ready_client);
24998
24999 let receipt = match shared.spawn_from_spec(spec).await {
25000 Ok(receipt) => receipt,
25001 Err(error) => panic!(
25002 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25003 error.code,
25004 error.message,
25005 ),
25006 };
25007 assert_eq!(receipt.provider, agent("kimi"));
25008 assert_eq!(receipt.context.as_ref(), Some(&context));
25009 assert!(receipt.context_binding_is_valid());
25010 timeout(Duration::from_secs(5), async {
25011 while !proof_path.is_file() {
25012 sleep(Duration::from_millis(10)).await;
25013 }
25014 })
25015 .await
25016 .expect("Kimi context-only fixture did not write its proof");
25017
25018 let proof = std::fs::read_to_string(&proof_path).unwrap();
25019 let lines = proof.lines().collect::<Vec<_>>();
25020 assert_eq!(lines.len(), 7);
25021 assert_eq!(lines[0], "context-only");
25022 assert!(Path::new(lines[1]).is_absolute());
25023 assert_ne!(
25024 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25025 std::fs::canonicalize(&workspace_root).unwrap(),
25026 );
25027 assert_eq!(
25028 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25029 std::fs::canonicalize(&workspace_root).unwrap(),
25030 );
25031 assert_eq!(lines[3], expected_context_hash);
25032 assert_eq!(lines[4], "g4a-context-pack-v1");
25033 assert_eq!(lines[5], "codex");
25034 assert_eq!(lines[6], "2");
25035 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25036 session.instance_id == receipt.session.session.instance_id
25037 && session.agent_id.as_str() == "kimi"
25038 }));
25039
25040 shutdown.request_shutdown().await.unwrap();
25041 timeout(Duration::from_secs(5), node_task)
25042 .await
25043 .unwrap()
25044 .unwrap()
25045 .unwrap();
25046 drop(shutdown);
25047 drop(shared);
25048 std::fs::remove_dir_all(root).unwrap();
25049 }
25050
25051 #[cfg(feature = "fixture")]
25052 #[test]
25053 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25054 let root = temporary_workspace_root("clean-exit-constructor");
25055 std::fs::create_dir_all(&root).unwrap();
25056 let config = construction_test_config("clean-exit-constructor")
25057 .with_state_path(root.join("node-state.json"))
25058 .unwrap();
25059 let server = NodeServer::new_clean_exit_fixture(
25060 config,
25061 root.clone(),
25062 root.join("started.marker"),
25063 root.join("release.signal"),
25064 )
25065 .unwrap();
25066 assert_eq!(
25067 server
25068 .shared
25069 .enabled_providers
25070 .iter()
25071 .map(AgentId::as_str)
25072 .collect::<Vec<_>>(),
25073 ["claude"],
25074 );
25075 drop(server);
25076 std::fs::remove_dir_all(root).unwrap();
25077 }
25078
25079 #[test]
25080 fn production_registry_provider_contract_manifest_is_exact() {
25081 let registry = active_registry().unwrap();
25082 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25083 assert_eq!(
25084 providers
25085 .iter()
25086 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25087 .collect::<Vec<_>>(),
25088 vec![
25089 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25090 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25092 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25093 ]
25094 );
25095 assert_eq!(
25096 adapters
25097 .iter()
25098 .map(|contract| (
25099 contract.provider.as_str(),
25100 contract.family,
25101 contract.adapter_id.as_str(),
25102 contract.revision.as_str(),
25103 ))
25104 .collect::<Vec<_>>(),
25105 vec![
25106 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25107 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25108 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25109 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25110 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25111 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25112 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25113 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25114 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25115 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25116 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25117 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25118 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25119 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25120 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25121 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25122 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25123 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25124 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25125 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25126 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25127 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25128 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25129 ]
25130 );
25131 }
25132
25133 fn construction_test_config(name: &str) -> NodeServerConfig {
25134 let workspace = WorkspaceConfig::new(
25135 WorkspaceId::new("primary").unwrap(),
25136 std::env::current_dir().unwrap(),
25137 )
25138 .unwrap();
25139 NodeServerConfig::new(
25140 format!(r"\\.\pipe\gate4agent-{name}"),
25141 "fixture-token",
25142 NodeId::new(name).unwrap(),
25143 [workspace],
25144 )
25145 .unwrap()
25146 }
25147
25148 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25149 let adapter = AdapterBinding::new(
25150 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25151 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25152 gate4agent_types::AdapterVerification::SyntheticFixture,
25153 )
25154 .unwrap();
25155 let agent_ids = ["claude", "codex", "kimi"]
25156 .into_iter()
25157 .map(|agent_id| AgentId::new(agent_id).unwrap())
25158 .collect::<Vec<_>>();
25159 let adapters = gate4agent_catalog::AdapterRegistry::new(
25160 [
25161 AdapterFamily::PtySemantic,
25162 AdapterFamily::Pipe,
25163 AdapterFamily::OneShot,
25164 AdapterFamily::Acp,
25165 AdapterFamily::Hook,
25166 AdapterFamily::ManagedHook,
25167 AdapterFamily::History,
25168 AdapterFamily::Resume,
25169 AdapterFamily::SessionOptions,
25170 AdapterFamily::CapabilityProbe,
25171 ]
25172 .into_iter()
25173 .map(|family| gate4agent_catalog::AdapterDescriptor {
25174 family,
25175 binding: adapter.clone(),
25176 agents: agent_ids.clone(),
25177 }),
25178 )
25179 .unwrap();
25180 let specs = agent_ids
25181 .iter()
25182 .map(|agent_id| {
25183 let agent_id = agent_id.as_str();
25184 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25185 spec.revision =
25186 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25187 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25188 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25189 adapter: adapter.clone(),
25190 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25191 launch_override: None,
25192 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25193 });
25194 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25195 adapter: adapter.clone(),
25196 launch_override: None,
25197 });
25198 spec.capabilities.adapters.hook = Some(adapter.clone());
25199 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25200 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25201 spec.capabilities.adapters.history = Some(adapter.clone());
25202 spec.capabilities.adapters.resume = Some(adapter.clone());
25203 spec.capabilities.adapters.session_options = Some(adapter.clone());
25204 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25205 spec
25206 })
25207 .collect::<Vec<_>>();
25208 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25209 }
25210
25211 #[test]
25219 fn node_server_construction_accepts_the_production_manifest() {
25220 let registry = active_registry().unwrap();
25221 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25222 assert_eq!(providers.len(), registry.iter().count());
25223 assert!(!providers.is_empty());
25224 let server = NodeServer::new_with_registry(
25225 construction_test_config("manifest-production-bound"),
25226 registry,
25227 )
25228 .unwrap();
25229 drop(server);
25230 }
25231
25232 #[test]
25233 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25234 let registry = maximum_size_provider_manifest_registry();
25235 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25236 assert_eq!(providers.len(), 3);
25237 assert_eq!(adapters.len(), 30);
25238 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25239 let error = match NodeServer::new_with_registry(
25240 construction_test_config("manifest-handshake-overflow"),
25241 registry,
25242 ) {
25243 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25244 Err(error) => error,
25245 };
25246 assert!(matches!(
25247 error,
25248 NodeServerError::ProviderContractManifest(message)
25249 if message.contains("authentication binding length")
25250 && message.contains("8192-byte limit")
25251 ));
25252 }
25253
25254 #[test]
25255 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25256 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25257 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25258 let registry = AgentRegistry::new([spec]).unwrap();
25259 assert!(matches!(
25260 provider_contract_manifest(®istry),
25261 Err(NodeServerError::ProviderContractManifest(message))
25262 if message.contains("exceeds the 128-byte limit")
25263 ));
25264 }
25265
25266 #[test]
25267 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25268 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25269 spec.revision = "invalid revision".to_owned();
25270 let registry = AgentRegistry::new([spec]).unwrap();
25271 assert!(matches!(
25272 provider_contract_manifest(®istry),
25273 Err(NodeServerError::ProviderContractManifest(message))
25274 if message.contains("bounded lowercase ASCII")
25275 ));
25276 }
25277
25278 #[test]
25279 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25280 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25281 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25282 let conflicting_binding = builtin_registry()
25283 .get_by_id("codex")
25284 .unwrap()
25285 .capabilities
25286 .adapters
25287 .one_shot
25288 .clone()
25289 .unwrap();
25290 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25291 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25292 pipe.adapter = transport_binding;
25293 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25294 let registry = AgentRegistry::new([spec]).unwrap();
25295 assert!(matches!(
25296 provider_contract_manifest(®istry),
25297 Err(NodeServerError::ProviderContractManifest(message))
25298 if message.contains("conflicting OneShot adapter bindings")
25299 ));
25300 }
25301
25302 #[test]
25303 fn public_node_failures_expose_only_fixed_categories() {
25304 let provider = failure(
25305 NodeFailureCode::BackendOperationFailed,
25306 r"provider bearer-secret failed at C:\private\session.jsonl",
25307 );
25308 assert_eq!(provider.message, "backend-operation-failed");
25309 assert!(!provider.message.contains("bearer-secret"));
25310 assert!(!provider.message.contains(r"C:\private"));
25311 let persistence = persistence_failure(io::Error::new(
25312 io::ErrorKind::Other,
25313 r"state write failed at C:\private\state-v1.json",
25314 ));
25315 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25316 assert!(!persistence.message.contains(r"C:\private"));
25317
25318 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25319 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25320 assert_eq!(
25321 generic.to_string(),
25322 "node durable state failed: durable-state-load-failed",
25323 );
25324 }
25325 }
25326
25327 #[test]
25328 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25329 let sequence = b"\x1b[1;5D".to_vec();
25330 assert_eq!(
25331 terminal_bytes_action(sequence.clone()).unwrap(),
25332 InputAction::TerminalBytes(sequence),
25333 );
25334 assert_eq!(
25335 terminal_bytes_action(Vec::new()).unwrap_err().code,
25336 NodeFailureCode::InvalidRequest,
25337 );
25338 assert_eq!(
25339 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25340 .unwrap_err()
25341 .code,
25342 NodeFailureCode::InvalidRequest,
25343 );
25344 }
25345
25346 #[test]
25347 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25348 let root = temporary_workspace_root("exclusive-state-owner");
25349 std::fs::create_dir_all(&root).unwrap();
25350 let workspace = WorkspaceConfig::new(
25351 WorkspaceId::new("primary").unwrap(),
25352 &root,
25353 )
25354 .unwrap();
25355 let state_path = root.join("state-v1.json");
25356 let endpoint = format!(
25357 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25358 std::process::id(),
25359 std::time::SystemTime::now()
25360 .duration_since(std::time::UNIX_EPOCH)
25361 .unwrap()
25362 .as_nanos(),
25363 );
25364 let config = NodeServerConfig::new(
25365 endpoint,
25366 "fixture-token",
25367 NodeId::new("node-1").unwrap(),
25368 [workspace],
25369 )
25370 .unwrap()
25371 .with_state_path(&state_path)
25372 .unwrap();
25373
25374 let first = NodeServer::new(config.clone()).unwrap();
25375 let error = match NodeServer::new(config.clone()) {
25376 Ok(_) => panic!("a second node server acquired the same durable state path"),
25377 Err(error) => error,
25378 };
25379 assert!(matches!(
25380 &error,
25381 NodeServerError::DurableState(error)
25382 if error.kind() == io::ErrorKind::WouldBlock
25383 ));
25384 assert_eq!(
25385 error.to_string(),
25386 "node durable state failed: durable-state-lock-failed",
25387 );
25388 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25389 drop(first);
25390 let second = NodeServer::new(config).unwrap();
25391 drop(second);
25392 std::fs::remove_dir_all(root).unwrap();
25393 }
25394
25395 #[test]
25396 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25397 let catalog = active_registry().unwrap();
25398 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25399 let workspace_id = WorkspaceId::new("primary").unwrap();
25400 let workspace = WorkspaceConfig::new(
25401 workspace_id.clone(),
25402 std::env::current_dir().unwrap(),
25403 )
25404 .unwrap();
25405 let shared = NodeShared::new(
25406 handle,
25407 "fixture-token".to_owned(),
25408 NodeId::new("node-1").unwrap(),
25409 vec![workspace.clone()],
25410 vec![agent("claude"), agent("codex"), agent("kimi")],
25411 );
25412 let instance_id = AgentInstanceId(17);
25413 let generation = SessionGeneration(3);
25414 assert!(shared.address_for(instance_id, generation).is_none());
25415 shared.bind_session(instance_id, workspace_id.clone(), generation);
25416 let address = SessionAddress {
25417 workspace_id,
25418 session: SessionKey { instance_id, generation },
25419 };
25420 assert_eq!(
25421 shared.workspace_root(&address.workspace_id).unwrap(),
25422 workspace.canonical_root,
25423 );
25424 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25425 shared.remove_binding(&address);
25426 assert!(shared.address_for(instance_id, generation).is_none());
25427 }
25428
25429 #[test]
25430 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25431 let address = SessionAddress {
25432 workspace_id: WorkspaceId::new("primary").unwrap(),
25433 session: SessionKey {
25434 instance_id: AgentInstanceId(7),
25435 generation: SessionGeneration(2),
25436 },
25437 };
25438 let record_id = SessionRecordId::new("record-a").unwrap();
25439 let size = gate4agent_types::TerminalSize {
25440 rows: 24,
25441 columns: 80,
25442 };
25443 let address_requests = [
25444 NodeRequest::Resume {
25445 session: address.clone(),
25446 terminal_size: size,
25447 initial_prompt: None,
25448 },
25449 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25450 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25451 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25452 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25453 NodeRequest::TerminalControl {
25454 session: address.clone(),
25455 control: TerminalControl::Enter,
25456 },
25457 NodeRequest::Resize { session: address.clone(), size },
25458 NodeRequest::Interrupt { session: address.clone() },
25459 NodeRequest::Stop { session: address.clone(), force: false },
25460 NodeRequest::Remove { session: address },
25461 ];
25462 for request in &address_requests {
25463 assert!(request_requires_open_provider_ids_with(
25464 request,
25465 |_| Some(agent("third-party-agent")),
25466 |_| Some(agent("claude")),
25467 ));
25468 assert!(!request_requires_open_provider_ids_with(
25469 request,
25470 |_| Some(agent("claude")),
25471 |_| Some(agent("claude")),
25472 ));
25473 assert!(request_requires_open_provider_ids_with(
25474 request,
25475 |_| None,
25476 |_| Some(agent("claude")),
25477 ));
25478 }
25479
25480 let record_requests = [
25481 NodeRequest::RenameSessionRecord {
25482 record_id: record_id.clone(),
25483 display_name: "renamed".to_owned(),
25484 },
25485 NodeRequest::ResumeSessionRecord {
25486 record_id: record_id.clone(),
25487 terminal_size: size,
25488 initial_prompt: None,
25489 },
25490 NodeRequest::ForgetSessionRecord { record_id },
25491 ];
25492 for request in &record_requests {
25493 assert!(request_requires_open_provider_ids_with(
25494 request,
25495 |_| Some(agent("claude")),
25496 |_| Some(agent("grok")),
25497 ));
25498 assert!(!request_requires_open_provider_ids_with(
25499 request,
25500 |_| Some(agent("claude")),
25501 |_| Some(agent("kimi")),
25502 ));
25503 assert!(request_requires_open_provider_ids_with(
25504 request,
25505 |_| Some(agent("claude")),
25506 |_| None,
25507 ));
25508 }
25509
25510 assert!(request_requires_open_provider_ids_with(
25511 &NodeRequest::Spawn {
25512 workspace_id: WorkspaceId::new("primary").unwrap(),
25513 provider: agent("third-party-agent"),
25514 mode: SessionMode::Pty,
25515 terminal_size: size,
25516 initial_prompt: None,
25517 },
25518 |_| None,
25519 |_| None,
25520 ));
25521 assert!(!request_requires_open_provider_ids_with(
25522 &NodeRequest::Snapshot,
25523 |_| None,
25524 |_| None,
25525 ));
25526 assert!(request_requires_open_provider_ids_with(
25527 &NodeRequest::ForgetContextPack {
25528 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25529 },
25530 |_| None,
25531 |_| None,
25532 ));
25533 }
25534
25535 #[test]
25536 fn legacy_projection_filters_open_record_replies_and_event_history() {
25537 let catalog = active_registry().unwrap();
25538 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539 let workspace = WorkspaceConfig::new(
25540 WorkspaceId::new("primary").unwrap(),
25541 std::env::current_dir().unwrap(),
25542 )
25543 .unwrap();
25544 let shared = NodeShared::new(
25545 handle,
25546 "fixture-token".to_owned(),
25547 NodeId::new("node-1").unwrap(),
25548 vec![workspace],
25549 vec![agent("claude"), agent("grok")],
25550 );
25551 let legacy = record("claude", "legacy-record");
25552 let open = record("grok", "open-record");
25553 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25554 record: legacy.clone(),
25555 });
25556 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25557 record: open.clone(),
25558 });
25559 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25560 record_id: legacy.record_id.clone(),
25561 });
25562 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25563 record_id: open.record_id.clone(),
25564 });
25565 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25566 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25567 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25568 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25569
25570 let mut record_reply = ResponseEnvelope {
25571 request_id: 1,
25572 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25573 };
25574 project_response_legacy_provider_ids(&shared, &mut record_reply);
25575 assert!(matches!(
25576 record_reply.result,
25577 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25578 ));
25579
25580 let mut resync = ResponseEnvelope {
25581 request_id: 2,
25582 result: Ok(NodeResponse::Resync {
25583 event_sequence: open_removed.sequence,
25584 oldest_available_sequence: 1,
25585 snapshot: shared.snapshot(),
25586 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25587 }),
25588 };
25589 project_response_legacy_provider_ids(&shared, &mut resync);
25590 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25591 panic!("legacy projection changed the resync response kind");
25592 };
25593 assert!(snapshot.session_records.iter().all(|item| {
25594 provider_id_is_legacy(&item.provider)
25595 }));
25596 assert_eq!(events.len(), 2);
25597 assert!(events.iter().all(|event| matches!(
25598 &event.event,
25599 NodeEvent::SessionRecordUpserted { record }
25600 if provider_id_is_legacy(&record.provider)
25601 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25602 }
25603
25604 #[test]
25605 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25606 let catalog = active_registry().unwrap();
25607 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25608 let workspace_id = WorkspaceId::new("primary").unwrap();
25609 let workspace = WorkspaceConfig::new(
25610 workspace_id.clone(),
25611 std::env::current_dir().unwrap(),
25612 )
25613 .unwrap();
25614 let shared = NodeShared::new(
25615 handle,
25616 "fixture-token".to_owned(),
25617 NodeId::new("node-1").unwrap(),
25618 vec![workspace],
25619 vec![agent("claude")],
25620 );
25621 for index in 0..CONTROL_SESSIONS_MAX {
25622 shared.bind_session(
25623 AgentInstanceId((index + 1) as u64),
25624 workspace_id.clone(),
25625 SessionGeneration(1),
25626 );
25627 }
25628 let error = shared.ensure_binding_capacity().unwrap_err();
25629 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25630 }
25631
25632 #[tokio::test]
25633 async fn provider_spawn_admission_rejects_before_session_mutation() {
25634 let catalog = active_registry().unwrap();
25635 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25636 let workspace = WorkspaceConfig::new(
25637 WorkspaceId::new("primary").unwrap(),
25638 std::env::current_dir().unwrap(),
25639 )
25640 .unwrap();
25641 let mut shared = NodeShared::new(
25642 handle,
25643 "fixture-token".to_owned(),
25644 NodeId::new("node-provider-admission").unwrap(),
25645 vec![workspace],
25646 vec![agent("claude")],
25647 );
25648 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25649 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25650 ])
25651 .unwrap();
25652 let terminal_size = gate4agent_types::TerminalSize {
25653 rows: 24,
25654 columns: 80,
25655 };
25656
25657 let unavailable = shared
25658 .spawn_session(
25659 WorkspaceId::new("primary").unwrap(),
25660 agent("claude"),
25661 SessionMode::Pty,
25662 terminal_size,
25663 None,
25664 )
25665 .await
25666 .unwrap_err();
25667 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25668 assert_eq!(unavailable.message, "backend-operation-failed");
25669
25670 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25671 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25672 agent("claude"),
25673 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25674 ),
25675 ])
25676 .unwrap();
25677 let semantic = shared
25678 .spawn_session(
25679 WorkspaceId::new("primary").unwrap(),
25680 agent("claude"),
25681 SessionMode::Inline,
25682 terminal_size,
25683 None,
25684 )
25685 .await
25686 .unwrap_err();
25687 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25688 assert_eq!(semantic.message, "unsupported-capability");
25689
25690 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25691 assert!(shared
25692 .session_bindings
25693 .lock()
25694 .unwrap_or_else(|poisoned| poisoned.into_inner())
25695 .is_empty());
25696 assert!(shared
25697 .session_records
25698 .lock()
25699 .unwrap_or_else(|poisoned| poisoned.into_inner())
25700 .records
25701 .is_empty());
25702 assert!(shared.handle.snapshot().sessions.is_empty());
25703 }
25704
25705 #[tokio::test]
25706 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25707 let catalog = active_registry().unwrap();
25708 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25709 let workspace_id = WorkspaceId::new("primary").unwrap();
25710 let workspace = WorkspaceConfig::new(
25711 workspace_id.clone(),
25712 std::env::current_dir().unwrap(),
25713 )
25714 .unwrap();
25715 let shared = NodeShared::new(
25716 handle,
25717 "fixture-token".to_owned(),
25718 NodeId::new("node-raw-spawn-record").unwrap(),
25719 vec![workspace],
25720 vec![agent("claude")],
25721 );
25722 let address = SessionAddress {
25723 workspace_id,
25724 session: SessionKey {
25725 instance_id: AgentInstanceId(71),
25726 generation: SessionGeneration::default(),
25727 },
25728 };
25729
25730 let record_id = shared
25731 .bind_spawn_session(
25732 &address,
25733 agent("claude"),
25734 SessionMode::Pty,
25735 ProviderRuntimePolicy::raw_pty(),
25736 SpawnRecordPolicy::Always,
25737 None,
25738 )
25739 .unwrap()
25740 .unwrap();
25741 shared
25742 .handle
25743 .dispatch(shared.prepare_command(ControlCommand::Register {
25744 instance_id: address.session.instance_id,
25745 agent_id: agent("claude"),
25746 transport: TransportKind::Pty,
25747 }))
25748 .unwrap();
25749 runtime.tick().await;
25750
25751 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25752 session.instance_id == address.session.instance_id
25753 && session.generation == address.session.generation
25754 }));
25755 let record = shared.record(&record_id).unwrap();
25756 assert_eq!(record.display_name, "claude #1");
25757 assert_eq!(record.state, ManagedSessionState::Live);
25758 assert_eq!(record.active_session.as_ref(), Some(&address));
25759 assert!(record.provider_session.is_none());
25760 let binding_record_id = shared
25761 .session_bindings
25762 .lock()
25763 .unwrap_or_else(|poisoned| poisoned.into_inner())
25764 .get(&address.session.instance_id)
25765 .and_then(|binding| binding.record_id.clone());
25766 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25767
25768 let renamed = shared
25769 .rename_session_record(&record_id, "raw review".to_owned())
25770 .unwrap();
25771 assert_eq!(renamed.record_id, record_id);
25772 assert_eq!(renamed.display_name, "raw review");
25773 shared.reconcile_managed_record(
25774 &record_id,
25775 &address,
25776 &ControlEvent {
25777 sequence: 1,
25778 command_id: None,
25779 instance_id: address.session.instance_id,
25780 generation: address.session.generation,
25781 event: ControlEventKind::Running { process_id: None },
25782 },
25783 false,
25784 false,
25785 );
25786 let running = shared.record(&record_id).unwrap();
25787 assert_eq!(running.state, ManagedSessionState::Live);
25788 assert_eq!(running.active_session.as_ref(), Some(&address));
25789 assert_eq!(running.display_name, "raw review");
25790
25791 shared.reconcile_managed_record(
25792 &record_id,
25793 &address,
25794 &ControlEvent {
25795 sequence: 2,
25796 command_id: None,
25797 instance_id: address.session.instance_id,
25798 generation: address.session.generation,
25799 event: ControlEventKind::Exited {
25800 exit_code: Some(0),
25801 forced: false,
25802 },
25803 },
25804 false,
25805 false,
25806 );
25807 let detached = shared.record(&record_id).unwrap();
25808 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25809 assert!(detached.active_session.is_none());
25810 assert_eq!(detached.display_name, "raw review");
25811 let renamed_detached = shared
25812 .rename_session_record(&record_id, "raw review detached".to_owned())
25813 .unwrap();
25814 assert_eq!(renamed_detached.record_id, record_id);
25815 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25816 assert!(renamed_detached.active_session.is_none());
25817 assert_eq!(renamed_detached.display_name, "raw review detached");
25818 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25819 }
25820
25821 #[test]
25822 fn legacy_raw_spawn_binding_remains_untracked() {
25823 let catalog = active_registry().unwrap();
25824 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25825 let workspace_id = WorkspaceId::new("primary").unwrap();
25826 let workspace = WorkspaceConfig::new(
25827 workspace_id.clone(),
25828 std::env::current_dir().unwrap(),
25829 )
25830 .unwrap();
25831 let shared = NodeShared::new(
25832 handle,
25833 "fixture-token".to_owned(),
25834 NodeId::new("node-legacy-raw-spawn").unwrap(),
25835 vec![workspace],
25836 vec![agent("claude")],
25837 );
25838 let address = SessionAddress {
25839 workspace_id,
25840 session: SessionKey {
25841 instance_id: AgentInstanceId(72),
25842 generation: SessionGeneration::default(),
25843 },
25844 };
25845
25846 let record_id = shared
25847 .bind_spawn_session(
25848 &address,
25849 agent("claude"),
25850 SessionMode::Pty,
25851 ProviderRuntimePolicy::raw_pty(),
25852 SpawnRecordPolicy::ProviderIdentityOnly,
25853 None,
25854 )
25855 .unwrap();
25856
25857 assert!(record_id.is_none());
25858 let binding_record_id = shared
25859 .session_bindings
25860 .lock()
25861 .unwrap_or_else(|poisoned| poisoned.into_inner())
25862 .get(&address.session.instance_id)
25863 .and_then(|binding| binding.record_id.clone());
25864 assert!(binding_record_id.is_none());
25865 assert!(shared.snapshot().session_records.is_empty());
25866 }
25867
25868 #[tokio::test]
25869 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25870 let catalog = active_registry().unwrap();
25871 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25872 let workspace_id = WorkspaceId::new("primary").unwrap();
25873 let workspace = WorkspaceConfig::new(
25874 workspace_id.clone(),
25875 std::env::current_dir().unwrap(),
25876 )
25877 .unwrap();
25878 let mut shared = NodeShared::new(
25879 handle,
25880 "fixture-token".to_owned(),
25881 NodeId::new("node-raw-semantic-gate").unwrap(),
25882 vec![workspace],
25883 vec![agent("claude")],
25884 );
25885 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25886 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25887 ])
25888 .unwrap();
25889 shared
25890 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25891 .unwrap();
25892 let address = SessionAddress {
25893 workspace_id,
25894 session: SessionKey {
25895 instance_id: AgentInstanceId(72),
25896 generation: SessionGeneration::default(),
25897 },
25898 };
25899 shared.bind_session_with_policy(
25900 &address,
25901 ProviderRuntimePolicy::raw_pty(),
25902 None,
25903 );
25904 shared
25905 .handle
25906 .dispatch(shared.prepare_command(ControlCommand::Register {
25907 instance_id: address.session.instance_id,
25908 agent_id: agent("claude"),
25909 transport: TransportKind::Pty,
25910 }))
25911 .unwrap();
25912 runtime.tick().await;
25913 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25914 let revision_before = shared.handle.snapshot().revision;
25915 let history_sequence_before = shared.current_sequence();
25916 let terminal_size = gate4agent_types::TerminalSize {
25917 rows: 24,
25918 columns: 80,
25919 };
25920
25921 for request in [
25922 NodeRequest::Prompt {
25923 session: address.clone(),
25924 text: "prompt".to_owned(),
25925 },
25926 NodeRequest::Paste {
25927 session: address.clone(),
25928 text: "paste".to_owned(),
25929 },
25930 NodeRequest::Resume {
25936 session: address.clone(),
25937 terminal_size,
25938 initial_prompt: Some("resume".to_owned()),
25939 },
25940 ] {
25941 let error = process_request_inner(
25942 &shared,
25943 77,
25944 ClientRole::Operator,
25945 request,
25946 )
25947 .await
25948 .unwrap_err();
25949 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25950 }
25951
25952 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25953 assert_eq!(shared.handle.snapshot().revision, revision_before);
25954 assert_eq!(shared.current_sequence(), history_sequence_before);
25955 let bindings = shared
25956 .session_bindings
25957 .lock()
25958 .unwrap_or_else(|poisoned| poisoned.into_inner());
25959 let binding = bindings.get(&address.session.instance_id).unwrap();
25960 assert_eq!(binding.generation, address.session.generation);
25961 assert!(binding.pending_resume.is_none());
25962 assert!(binding.record_id.is_none());
25963 }
25964
25965 #[tokio::test]
25966 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25967 let catalog = active_registry().unwrap();
25968 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25969 let primary = WorkspaceConfig::new(
25970 WorkspaceId::new("primary").unwrap(),
25971 std::env::current_dir().unwrap(),
25972 )
25973 .unwrap();
25974 let shared = NodeShared::new(
25975 handle,
25976 "fixture-token".to_owned(),
25977 NodeId::new("node-1").unwrap(),
25978 vec![primary],
25979 vec![agent("claude")],
25980 );
25981 shared
25982 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25983 .unwrap();
25984 let before = shared.snapshot();
25985 let error = process_request_inner(
25986 &shared,
25987 77,
25988 ClientRole::Operator,
25989 NodeRequest::RegisterWorkspace {
25990 workspace_id: WorkspaceId::new("foreign").unwrap(),
25991 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25992 },
25993 )
25994 .await
25995 .unwrap_err();
25996 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25997 assert_eq!(error.message, "invalid-request");
25998 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25999 }
26000
26001 #[tokio::test]
26002 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26003 let catalog = active_registry().unwrap();
26004 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26005 let primary_id = WorkspaceId::new("primary").unwrap();
26006 let primary = WorkspaceConfig::new(
26007 primary_id.clone(),
26008 std::env::current_dir().unwrap(),
26009 )
26010 .unwrap();
26011 let shared = NodeShared::new(
26012 handle,
26013 "fixture-token".to_owned(),
26014 NodeId::new("node-1").unwrap(),
26015 vec![primary.clone()],
26016 vec![agent("claude")],
26017 );
26018 let secondary_id = WorkspaceId::new("secondary").unwrap();
26019 let secondary = shared
26020 .register_workspace(
26021 secondary_id.clone(),
26022 std::env::temp_dir().to_string_lossy().into_owned(),
26023 )
26024 .await
26025 .unwrap();
26026 assert_eq!(secondary.workspace_id, secondary_id);
26027 assert!(secondary.sessions.is_empty());
26028 assert_eq!(shared.snapshot().workspaces.len(), 2);
26029
26030 let duplicate_id = shared
26031 .register_workspace(
26032 secondary_id.clone(),
26033 primary.canonical_root().to_owned(),
26034 )
26035 .await
26036 .unwrap_err();
26037 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26038 let duplicate_root = shared
26039 .register_workspace(
26040 WorkspaceId::new("duplicate-root").unwrap(),
26041 windows_path_text(&secondary.canonical_root).to_owned(),
26042 )
26043 .await
26044 .unwrap_err();
26045 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26046
26047 let address = SessionAddress {
26048 workspace_id: secondary_id.clone(),
26049 session: SessionKey {
26050 instance_id: AgentInstanceId(41),
26051 generation: SessionGeneration(1),
26052 },
26053 };
26054 shared.bind_session(
26055 address.session.instance_id,
26056 secondary_id.clone(),
26057 address.session.generation,
26058 );
26059 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26060 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26061 shared.remove_binding(&address);
26062 shared.unregister_workspace(&secondary_id).unwrap();
26063 assert_eq!(shared.snapshot().workspaces.len(), 1);
26064
26065 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26066 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26067 let history = shared
26068 .history
26069 .lock()
26070 .unwrap_or_else(|poisoned| poisoned.into_inner());
26071 assert!(matches!(
26072 &history.events[0],
26073 NodeEventEnvelope {
26074 sequence: 1,
26075 event: NodeEvent::WorkspaceAdded { workspace },
26076 } if workspace.workspace_id == secondary_id
26077 ));
26078 assert!(matches!(
26079 &history.events[1],
26080 NodeEventEnvelope {
26081 sequence: 2,
26082 event: NodeEvent::WorkspaceRemoved { workspace_id },
26083 } if workspace_id == &secondary_id
26084 ));
26085 }
26086
26087 #[tokio::test]
26088 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26089 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26090 std::fs::create_dir_all(&secondary_root).unwrap();
26091 let catalog = active_registry().unwrap();
26092 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26093 let primary_id = WorkspaceId::new("primary").unwrap();
26094 let secondary_id = WorkspaceId::new("secondary").unwrap();
26095 let shared = NodeShared::new(
26096 handle,
26097 "fixture-token".to_owned(),
26098 NodeId::new("node-session-reference-index").unwrap(),
26099 vec![
26100 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26101 .unwrap(),
26102 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26103 ],
26104 vec![agent("claude"), agent("codex")],
26105 );
26106 let identity = ProviderSessionIdentity {
26107 key: gate4agent_types::ProviderSessionKey::SessionId,
26108 id: "provider-session-42".to_owned(),
26109 transcript_path: None,
26110 };
26111 let index_request = |workspace_id: WorkspaceId,
26112 provider: AgentId,
26113 identity: ProviderSessionIdentity,
26114 display_name: &str| {
26115 NodeRequest::IndexProviderSession {
26116 workspace_id,
26117 provider,
26118 identity,
26119 display_name: display_name.to_owned(),
26120 }
26121 };
26122 let request = index_request(
26123 primary_id.clone(),
26124 agent("claude"),
26125 identity.clone(),
26126 "release shepherd",
26127 );
26128 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26129 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26130 }));
26131 let observer = process_request_inner(
26132 &shared,
26133 77,
26134 ClientRole::Observer,
26135 request.clone(),
26136 )
26137 .await
26138 .unwrap_err();
26139 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26140 let uncontrolled = process_request_inner(
26141 &shared,
26142 77,
26143 ClientRole::Operator,
26144 request.clone(),
26145 )
26146 .await
26147 .unwrap_err();
26148 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26149 shared
26150 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26151 .unwrap();
26152
26153 let indexed = process_request_inner(
26154 &shared,
26155 77,
26156 ClientRole::Operator,
26157 request.clone(),
26158 )
26159 .await
26160 .unwrap();
26161 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26162 panic!("provider session reference index returned another response");
26163 };
26164 assert_eq!(record.state, ManagedSessionState::Dormant);
26165 assert_eq!(record.mode, SessionMode::Pty);
26166 assert_eq!(record.workspace_id, primary_id);
26167 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26168 assert!(record.active_session.is_none());
26169 assert!(record.environment_profile.is_none());
26170 assert!(record.bundle.is_none());
26171 assert!(record.context.is_none());
26172
26173 let repeated = process_request_inner(
26174 &shared,
26175 77,
26176 ClientRole::Operator,
26177 index_request(
26178 primary_id.clone(),
26179 agent("claude"),
26180 identity.clone(),
26181 "ignored replacement",
26182 ),
26183 )
26184 .await
26185 .unwrap();
26186 assert!(matches!(
26187 repeated,
26188 NodeResponse::ProviderSessionIndexed { record: ref existing }
26189 if existing.record_id == record.record_id
26190 && existing.display_name == "release shepherd"
26191 ));
26192 let conflicting_workspace = process_request_inner(
26193 &shared,
26194 77,
26195 ClientRole::Operator,
26196 index_request(
26197 secondary_id,
26198 agent("claude"),
26199 identity.clone(),
26200 "release shepherd",
26201 ),
26202 )
26203 .await
26204 .unwrap_err();
26205 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26206 let conflicting_provider = process_request_inner(
26207 &shared,
26208 77,
26209 ClientRole::Operator,
26210 index_request(
26211 primary_id.clone(),
26212 agent("codex"),
26213 identity.clone(),
26214 "release shepherd",
26215 ),
26216 )
26217 .await
26218 .unwrap_err();
26219 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26220 let transcript_rejected = process_request_inner(
26221 &shared,
26222 77,
26223 ClientRole::Operator,
26224 index_request(
26225 primary_id,
26226 agent("claude"),
26227 ProviderSessionIdentity {
26228 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26229 ..identity
26230 },
26231 "release shepherd",
26232 ),
26233 )
26234 .await
26235 .unwrap_err();
26236 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26237 let upserts = shared
26238 .history
26239 .lock()
26240 .unwrap_or_else(|poisoned| poisoned.into_inner())
26241 .events
26242 .iter()
26243 .filter(|event| matches!(
26244 &event.event,
26245 NodeEvent::SessionRecordUpserted { record: published }
26246 if published.record_id == record.record_id
26247 ))
26248 .count();
26249 assert_eq!(upserts, 1);
26250 std::fs::remove_dir_all(secondary_root).unwrap();
26251 }
26252
26253 #[tokio::test]
26254 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26255 let fixture_root = temporary_workspace_root("standalone-independent");
26256 let target = fixture_root.join("independent");
26257 std::fs::create_dir_all(&fixture_root).unwrap();
26258 let catalog = active_registry().unwrap();
26259 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26260 let selected = WorkspaceConfig::new(
26261 WorkspaceId::new("primary").unwrap(),
26262 std::env::current_dir().unwrap(),
26263 )
26264 .unwrap();
26265 let selected_root = selected.canonical_root().to_owned();
26266 let shared = NodeShared::new(
26267 handle,
26268 "fixture-token".to_owned(),
26269 NodeId::new("node-standalone").unwrap(),
26270 vec![selected],
26271 vec![agent("claude")],
26272 );
26273 let workspace_id = WorkspaceId::new("independent").unwrap();
26274 let request = NodeRequest::CreateStandaloneWorkspace {
26275 workspace_id: workspace_id.clone(),
26276 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26277 initial_branch: Some("main".to_owned()),
26278 };
26279 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26280 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26281 }));
26282 assert!(!request_uses_unnegotiated_capability(
26283 &request,
26284 &baseline_capabilities().unwrap(),
26285 ));
26286 assert!(request_uses_unnegotiated_capability(&request, &[]));
26287
26288 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26289 .await
26290 .unwrap_err();
26291 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26292 assert!(!target.exists());
26293 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26294 .await
26295 .unwrap_err();
26296 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26297 assert!(!target.exists());
26298 shared
26299 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26300 .unwrap();
26301
26302 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26303 .await
26304 .unwrap();
26305 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26306 panic!("standalone workspace creation returned another response");
26307 };
26308 assert_eq!(workspace.workspace_id, workspace_id);
26309 assert!(target.join(".git").is_dir());
26310 let branch = run_git_bounded(
26311 target.to_str().unwrap(),
26312 &["branch", "--show-current"],
26313 GIT_OUTPUT_MAX_BYTES,
26314 )
26315 .await
26316 .unwrap();
26317 assert!(branch.success);
26318 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26319 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26320 assert_eq!(worktrees.len(), 1);
26321 let listed_root = WorkspaceConfig::new(
26322 WorkspaceId::new("listed-independent").unwrap(),
26323 &worktrees[0].path,
26324 )
26325 .unwrap();
26326 assert!(platform::roots_equal(
26327 listed_root.canonical_root(),
26328 windows_path_text(&workspace.canonical_root),
26329 ));
26330 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26331 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26332 snapshot.workspace_id == workspace_id
26333 && snapshot.canonical_root == workspace.canonical_root
26334 }));
26335 assert!(shared
26336 .history
26337 .lock()
26338 .unwrap_or_else(|poisoned| poisoned.into_inner())
26339 .events
26340 .iter()
26341 .any(|event| matches!(
26342 &event.event,
26343 NodeEvent::WorkspaceAdded { workspace }
26344 if workspace.workspace_id == workspace_id
26345 )));
26346 std::fs::remove_dir_all(fixture_root).unwrap();
26347 }
26348
26349 #[tokio::test]
26350 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26351 let fixture_root = temporary_workspace_root("standalone-compensation");
26352 let created_target = fixture_root.join("created-target");
26353 let existing_target = fixture_root.join("existing-target");
26354 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26355 let invalid_state_path = invalid_state_parent.join("state.json");
26356 std::fs::create_dir_all(&existing_target).unwrap();
26357 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26358 let catalog = active_registry().unwrap();
26359 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26360 let primary = WorkspaceConfig::new(
26361 WorkspaceId::new("primary").unwrap(),
26362 std::env::current_dir().unwrap(),
26363 )
26364 .unwrap();
26365 let mut shared = NodeShared::new(
26366 handle,
26367 "fixture-token".to_owned(),
26368 NodeId::new("node-standalone-compensation").unwrap(),
26369 vec![primary],
26370 vec![agent("claude")],
26371 );
26372 shared.state_path = Some(invalid_state_path);
26373
26374 let created_recovery = shared
26375 .create_standalone_workspace(
26376 WorkspaceId::new("compensated").unwrap(),
26377 created_target.to_string_lossy().into_owned(),
26378 Some("main".to_owned()),
26379 )
26380 .await
26381 .unwrap_err();
26382 assert_eq!(
26383 created_recovery.code,
26384 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26385 );
26386 assert!(created_target.join(".git").is_dir());
26387 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26388 workspace.workspace_id.as_str() != "compensated"
26389 }));
26390
26391 let existing_recovery = shared
26392 .create_standalone_workspace(
26393 WorkspaceId::new("restored").unwrap(),
26394 existing_target.to_string_lossy().into_owned(),
26395 None,
26396 )
26397 .await
26398 .unwrap_err();
26399 assert_eq!(
26400 existing_recovery.code,
26401 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26402 );
26403 assert!(existing_target.is_dir());
26404 assert!(existing_target.join(".git").is_dir());
26405 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26406 workspace.workspace_id.as_str() != "restored"
26407 }));
26408
26409 let registered_empty = fixture_root.join("registered-empty");
26410 std::fs::create_dir(®istered_empty).unwrap();
26411 let catalog = active_registry().unwrap();
26412 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26413 let registered = WorkspaceConfig::new(
26414 WorkspaceId::new("registered-empty").unwrap(),
26415 ®istered_empty,
26416 )
26417 .unwrap();
26418 let duplicate_shared = NodeShared::new(
26419 handle,
26420 "fixture-token".to_owned(),
26421 NodeId::new("node-standalone-duplicate-root").unwrap(),
26422 vec![registered],
26423 vec![agent("claude")],
26424 );
26425 let duplicate = duplicate_shared
26426 .create_standalone_workspace(
26427 WorkspaceId::new("duplicate-root").unwrap(),
26428 registered_empty.to_string_lossy().into_owned(),
26429 None,
26430 )
26431 .await
26432 .unwrap_err();
26433 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26434 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26435 std::fs::remove_dir_all(fixture_root).unwrap();
26436 }
26437
26438 #[test]
26439 fn managed_record_id_collision_preserves_the_existing_record() {
26440 let catalog = active_registry().unwrap();
26441 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26442 let workspace = WorkspaceConfig::new(
26443 WorkspaceId::new("primary").unwrap(),
26444 std::env::current_dir().unwrap(),
26445 )
26446 .unwrap();
26447 let shared = NodeShared::new(
26448 handle,
26449 "fixture-token".to_owned(),
26450 NodeId::new("node-1").unwrap(),
26451 vec![workspace.clone()],
26452 vec![agent("claude")],
26453 );
26454 let record_id = SessionRecordId::new("sr-collision").unwrap();
26455 let original = ManagedSessionRecord {
26456 record_id: record_id.clone(),
26457 display_name: "original".to_owned(),
26458 provider: agent("claude"),
26459 mode: SessionMode::Pty,
26460 state: ManagedSessionState::Dormant,
26461 workspace_id: workspace.workspace_id,
26462 canonical_root: opaque_windows_path(workspace.canonical_root),
26463 provider_session: None,
26464 active_session: None,
26465 environment_profile: None,
26466 bundle: None,
26467 context_id: None,
26468 context: None,
26469 exported_context: None,
26470 task_binding: None,
26471 created_at_unix_ms: 1,
26472 updated_at_unix_ms: 1,
26473 last_error: None,
26474 };
26475 shared.insert_record(original.clone()).unwrap();
26476 let mut collision = original.clone();
26477 collision.display_name = "replacement".to_owned();
26478 let error = shared.insert_record(collision).unwrap_err();
26479 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26480 assert_eq!(shared.record(&record_id).unwrap(), original);
26481 }
26482
26483 #[test]
26484 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26485 let catalog = active_registry().unwrap();
26486 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26487 let workspace = WorkspaceConfig::new(
26488 WorkspaceId::new("primary").unwrap(),
26489 std::env::current_dir().unwrap(),
26490 )
26491 .unwrap();
26492 let shared = NodeShared::new(
26493 handle,
26494 "fixture-token".to_owned(),
26495 NodeId::new("node-1").unwrap(),
26496 vec![workspace.clone()],
26497 vec![agent("claude")],
26498 );
26499 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26500 shared
26501 .insert_record(ManagedSessionRecord {
26502 record_id: record_id.clone(),
26503 display_name: "runtime error".to_owned(),
26504 provider: agent("claude"),
26505 mode: SessionMode::Pty,
26506 state: ManagedSessionState::IdentityPending,
26507 workspace_id: workspace.workspace_id,
26508 canonical_root: opaque_windows_path(workspace.canonical_root),
26509 provider_session: None,
26510 active_session: None,
26511 environment_profile: None,
26512 bundle: None,
26513 context_id: None,
26514 context: None,
26515 exported_context: None,
26516 task_binding: None,
26517 created_at_unix_ms: 1,
26518 updated_at_unix_ms: 1,
26519 last_error: None,
26520 })
26521 .unwrap();
26522
26523 shared
26524 .mark_record_error(
26525 &record_id,
26526 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26527 )
26528 .unwrap();
26529 let record = shared.record(&record_id).unwrap();
26530 assert_eq!(record.state, ManagedSessionState::Unavailable);
26531 assert_eq!(
26532 record.last_error.as_deref(),
26533 Some("provider-runtime-failed"),
26534 );
26535 let snapshot = shared.snapshot();
26536 assert_eq!(snapshot.session_records, vec![record]);
26537 }
26538
26539 #[test]
26548 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26549 let catalog = active_registry().unwrap();
26550 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26551 let workspace = WorkspaceConfig::new(
26552 WorkspaceId::new("primary").unwrap(),
26553 std::env::current_dir().unwrap(),
26554 )
26555 .unwrap();
26556 let shared = NodeShared::new(
26557 handle,
26558 "fixture-token".to_owned(),
26559 NodeId::new("node-1").unwrap(),
26560 vec![workspace],
26561 vec![agent("claude")],
26562 );
26563
26564 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26565
26566 shared.record_rejected_command(
26567 CommandId(1),
26568 "agent 'claude' does not support transport Acp".to_owned(),
26569 true,
26570 );
26571 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26573
26574 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26575 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26576 assert!(reason.unsupported_transport);
26577
26578 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26580
26581 shared.record_rejected_command(
26586 CommandId(3),
26587 "backend busy".to_owned(),
26588 false,
26589 );
26590 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26591 assert!(!unclassified.unsupported_transport);
26592 }
26593
26594 #[test]
26595 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26596 let catalog = active_registry().unwrap();
26597 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26598 let workspace = WorkspaceConfig::new(
26599 WorkspaceId::new("primary").unwrap(),
26600 std::env::current_dir().unwrap(),
26601 )
26602 .unwrap();
26603 let shared = NodeShared::new(
26604 handle,
26605 "fixture-token".to_owned(),
26606 NodeId::new("node-identity-policy").unwrap(),
26607 vec![workspace.clone()],
26608 vec![agent("claude")],
26609 );
26610 let address = SessionAddress {
26611 workspace_id: workspace.workspace_id.clone(),
26612 session: SessionKey {
26613 instance_id: AgentInstanceId(90),
26614 generation: SessionGeneration(1),
26615 },
26616 };
26617 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26618 shared
26619 .insert_record(ManagedSessionRecord {
26620 record_id: record_id.clone(),
26621 display_name: "policy denied".to_owned(),
26622 provider: agent("claude"),
26623 mode: SessionMode::Pty,
26624 state: ManagedSessionState::IdentityPending,
26625 workspace_id: workspace.workspace_id,
26626 canonical_root: opaque_windows_path(workspace.canonical_root),
26627 provider_session: None,
26628 active_session: Some(address.clone()),
26629 environment_profile: None,
26630 bundle: None,
26631 context_id: None,
26632 context: None,
26633 exported_context: None,
26634 task_binding: None,
26635 created_at_unix_ms: 1,
26636 updated_at_unix_ms: 1,
26637 last_error: None,
26638 })
26639 .unwrap();
26640 let event = ControlEvent {
26641 sequence: 1,
26642 command_id: None,
26643 instance_id: address.session.instance_id,
26644 generation: address.session.generation,
26645 event: ControlEventKind::ProviderEvent {
26646 sequence: 1,
26647 source: gate4agent_types::ProviderSource {
26648 family: AdapterFamily::PtySemantic,
26649 binding: AdapterBinding::new(
26650 gate4agent_types::AdapterId::new("claude").unwrap(),
26651 "fixture/v1",
26652 gate4agent_types::AdapterVerification::SyntheticFixture,
26653 )
26654 .unwrap(),
26655 },
26656 source_sequence: 1,
26657 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26658 identity: gate4agent_types::ProviderSessionIdentity {
26659 key: gate4agent_types::ProviderSessionKey::SessionId,
26660 id: "provider-session".to_owned(),
26661 transcript_path: None,
26662 },
26663 },
26664 },
26665 };
26666
26667 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26668
26669 let record = shared.record(&record_id).unwrap();
26670 assert!(record.provider_session.is_none());
26671 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26672 assert_eq!(record.active_session.as_ref(), Some(&address));
26673 }
26674
26675 #[test]
26676 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26677 let secondary_root = temporary_workspace_root("identity-scope");
26678 std::fs::create_dir_all(&secondary_root).unwrap();
26679 let catalog = active_registry().unwrap();
26680 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26681 let primary = WorkspaceConfig::new(
26682 WorkspaceId::new("primary").unwrap(),
26683 std::env::current_dir().unwrap(),
26684 )
26685 .unwrap();
26686 let secondary = WorkspaceConfig::new(
26687 WorkspaceId::new("secondary").unwrap(),
26688 &secondary_root,
26689 )
26690 .unwrap();
26691 let shared = NodeShared::new(
26692 handle,
26693 "fixture-token".to_owned(),
26694 NodeId::new("node-1").unwrap(),
26695 vec![primary.clone(), secondary.clone()],
26696 vec![agent("claude")],
26697 );
26698 let identity = gate4agent_types::ProviderSessionIdentity {
26699 key: gate4agent_types::ProviderSessionKey::SessionId,
26700 id: "provider-session".to_owned(),
26701 transcript_path: None,
26702 };
26703 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26704 shared
26705 .insert_record(ManagedSessionRecord {
26706 record_id: dormant_id.clone(),
26707 display_name: "dormant".to_owned(),
26708 provider: agent("claude"),
26709 mode: SessionMode::Pty,
26710 state: ManagedSessionState::Dormant,
26711 workspace_id: primary.workspace_id,
26712 canonical_root: opaque_windows_path(primary.canonical_root),
26713 provider_session: Some(identity.clone()),
26714 active_session: None,
26715 environment_profile: None,
26716 bundle: None,
26717 context_id: None,
26718 context: None,
26719 exported_context: None,
26720 task_binding: None,
26721 created_at_unix_ms: 1,
26722 updated_at_unix_ms: 1,
26723 last_error: None,
26724 })
26725 .unwrap();
26726 let address = SessionAddress {
26727 workspace_id: secondary.workspace_id.clone(),
26728 session: SessionKey {
26729 instance_id: AgentInstanceId(91),
26730 generation: SessionGeneration(1),
26731 },
26732 };
26733 let current_id = SessionRecordId::new("sr-current").unwrap();
26734 shared
26735 .insert_record(ManagedSessionRecord {
26736 record_id: current_id.clone(),
26737 display_name: "current".to_owned(),
26738 provider: agent("claude"),
26739 mode: SessionMode::Pty,
26740 state: ManagedSessionState::IdentityPending,
26741 workspace_id: secondary.workspace_id,
26742 canonical_root: opaque_windows_path(secondary.canonical_root),
26743 provider_session: None,
26744 active_session: Some(address.clone()),
26745 environment_profile: None,
26746 bundle: None,
26747 context_id: None,
26748 context: None,
26749 exported_context: None,
26750 task_binding: None,
26751 created_at_unix_ms: 2,
26752 updated_at_unix_ms: 2,
26753 last_error: None,
26754 })
26755 .unwrap();
26756 shared.bind_managed_session(
26757 &address,
26758 current_id.clone(),
26759 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26760 None,
26761 );
26762 let event = ControlEvent {
26763 sequence: 1,
26764 command_id: None,
26765 instance_id: address.session.instance_id,
26766 generation: address.session.generation,
26767 event: ControlEventKind::ProviderEvent {
26768 sequence: 1,
26769 source: gate4agent_types::ProviderSource {
26770 family: gate4agent_types::AdapterFamily::PtySemantic,
26771 binding: gate4agent_types::AdapterBinding::new(
26772 gate4agent_types::AdapterId::new("claude").unwrap(),
26773 "fixture/v1",
26774 gate4agent_types::AdapterVerification::SyntheticFixture,
26775 )
26776 .unwrap(),
26777 },
26778 source_sequence: 1,
26779 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26780 identity,
26781 },
26782 },
26783 };
26784 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26785
26786 let binding = shared
26787 .session_bindings
26788 .lock()
26789 .unwrap_or_else(|poisoned| poisoned.into_inner())
26790 .get(&address.session.instance_id)
26791 .cloned()
26792 .unwrap();
26793 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26794 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26795 let current = shared.record(¤t_id).unwrap();
26796 assert_eq!(current.state, ManagedSessionState::Unavailable);
26797 assert_eq!(current.active_session.as_ref(), Some(&address));
26798 assert_eq!(
26799 current.last_error.as_deref(),
26800 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26801 );
26802 std::fs::remove_dir_all(secondary_root).unwrap();
26803 }
26804
26805 #[tokio::test]
26806 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26807 let root = temporary_workspace_root("semantic-provider-session");
26808 std::fs::create_dir_all(&root).unwrap();
26809 let state_path = root.join("state-v1.json");
26810 let catalog = active_registry().unwrap();
26811 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26812 let workspace = WorkspaceConfig::new(
26813 WorkspaceId::new("primary").unwrap(),
26814 &root,
26815 )
26816 .unwrap();
26817 let node_id = NodeId::new("node-semantic-identity").unwrap();
26818 let shared = NodeShared::new_with_incarnation(
26819 handle,
26820 "fixture-token".to_owned(),
26821 node_id.clone(),
26822 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26823 vec![workspace.clone()],
26824 vec![agent("claude")],
26825 ProviderRuntimeStatuses::default(),
26826 None,
26827 Vec::new(),
26828 Vec::new(),
26829 SpawnProfileRegistry::default(),
26830 None,
26831 None,
26832 None,
26833 Some(state_path.clone()),
26834 Vec::new(),
26835 Vec::new(),
26836 Vec::new(),
26837 Vec::new(),
26838 None,
26839 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26840 );
26841 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26842 key: gate4agent_types::ProviderSessionKey::SessionId,
26843 id: "provider-session".to_owned(),
26844 transcript_path: None,
26845 };
26846 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26847 shared
26848 .insert_record(ManagedSessionRecord {
26849 record_id: durable_id.clone(),
26850 display_name: "durable".to_owned(),
26851 provider: agent("claude"),
26852 mode: SessionMode::Pty,
26853 state: ManagedSessionState::Dormant,
26854 workspace_id: workspace.workspace_id.clone(),
26855 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26856 provider_session: Some(persisted_identity.clone()),
26857 active_session: None,
26858 environment_profile: None,
26859 bundle: None,
26860 context_id: None,
26861 context: None,
26862 exported_context: None,
26863 task_binding: None,
26864 created_at_unix_ms: 1,
26865 updated_at_unix_ms: 1,
26866 last_error: None,
26867 })
26868 .unwrap();
26869 let address = SessionAddress {
26870 workspace_id: workspace.workspace_id.clone(),
26871 session: SessionKey {
26872 instance_id: AgentInstanceId(92),
26873 generation: SessionGeneration(2),
26874 },
26875 };
26876 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26877 shared
26878 .insert_record(ManagedSessionRecord {
26879 record_id: pending_id.clone(),
26880 display_name: "pending".to_owned(),
26881 provider: agent("claude"),
26882 mode: SessionMode::Pty,
26883 state: ManagedSessionState::IdentityPending,
26884 workspace_id: workspace.workspace_id.clone(),
26885 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26886 provider_session: None,
26887 active_session: Some(address.clone()),
26888 environment_profile: None,
26889 bundle: None,
26890 context_id: None,
26891 context: None,
26892 exported_context: None,
26893 task_binding: None,
26894 created_at_unix_ms: 2,
26895 updated_at_unix_ms: 2,
26896 last_error: None,
26897 })
26898 .unwrap();
26899 shared.bind_managed_session(
26900 &address,
26901 pending_id.clone(),
26902 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26903 None,
26904 );
26905 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26906 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26907 ..persisted_identity
26908 };
26909 let event = ControlEvent {
26910 sequence: 1,
26911 command_id: None,
26912 instance_id: address.session.instance_id,
26913 generation: address.session.generation,
26914 event: ControlEventKind::ProviderEvent {
26915 sequence: 1,
26916 source: gate4agent_types::ProviderSource {
26917 family: gate4agent_types::AdapterFamily::PtySemantic,
26918 binding: gate4agent_types::AdapterBinding::new(
26919 gate4agent_types::AdapterId::new("claude").unwrap(),
26920 "fixture/v1",
26921 gate4agent_types::AdapterVerification::SyntheticFixture,
26922 )
26923 .unwrap(),
26924 },
26925 source_sequence: 1,
26926 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26927 identity: observed_identity.clone(),
26928 },
26929 },
26930 };
26931 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26932
26933 let snapshot = shared.snapshot();
26934 assert_eq!(snapshot.session_records.len(), 1);
26935 let live = &snapshot.session_records[0];
26936 assert_eq!(live.record_id, durable_id);
26937 assert_eq!(live.state, ManagedSessionState::Live);
26938 assert_eq!(live.active_session.as_ref(), Some(&address));
26939 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26940 let binding = shared
26941 .session_bindings
26942 .lock()
26943 .unwrap_or_else(|poisoned| poisoned.into_inner())
26944 .get(&address.session.instance_id)
26945 .cloned()
26946 .unwrap();
26947 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26948
26949 let mut duplicate = live.clone();
26950 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26951 duplicate.display_name = "duplicate".to_owned();
26952 duplicate.state = ManagedSessionState::Dormant;
26953 duplicate.active_session = None;
26954 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26955 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26956 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26957 assert_eq!(shared.snapshot().session_records.len(), 1);
26958
26959 let terminal_size = gate4agent_types::TerminalSize {
26960 rows: 24,
26961 columns: 80,
26962 };
26963 let (first_busy, second_busy) = tokio::join!(
26964 shared.resume_session_record(&durable_id, terminal_size, None),
26965 shared.resume_session_record(&durable_id, terminal_size, None),
26966 );
26967 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26968 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26969 assert_eq!(busy.message, "session-record-busy");
26970 }
26971
26972 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26973 assert_eq!(loaded.records.len(), 1);
26974 assert_eq!(loaded.records[0].record_id, durable_id);
26975 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26976 assert_eq!(
26977 loaded.records[0]
26978 .provider_session
26979 .as_ref()
26980 .unwrap()
26981 .transcript_path,
26982 None,
26983 );
26984 let catalog = active_registry().unwrap();
26985 let (reloaded_handle, _reloaded_runtime) =
26986 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26987 let reloaded = NodeShared::new_with_incarnation(
26988 reloaded_handle,
26989 "fixture-token".to_owned(),
26990 node_id,
26991 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26992 vec![workspace],
26993 vec![agent("claude")],
26994 ProviderRuntimeStatuses::default(),
26995 None,
26996 Vec::new(),
26997 Vec::new(),
26998 SpawnProfileRegistry::default(),
26999 None,
27000 None,
27001 None,
27002 None,
27003 loaded.records,
27004 loaded.managed_worktrees,
27005 loaded.managed_worktree_tombstones,
27006 loaded.materializations,
27007 loaded.warning,
27008 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27009 );
27010 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27011 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27012 std::fs::remove_dir_all(root).unwrap();
27013 }
27014
27015 #[tokio::test]
27016 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27017 let secondary_root = temporary_workspace_root("workspace-restore");
27018 let other_root = temporary_workspace_root("workspace-conflict");
27019 std::fs::create_dir_all(&secondary_root).unwrap();
27020 std::fs::create_dir_all(&other_root).unwrap();
27021 let catalog = active_registry().unwrap();
27022 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27023 let primary = WorkspaceConfig::new(
27024 WorkspaceId::new("primary").unwrap(),
27025 std::env::current_dir().unwrap(),
27026 )
27027 .unwrap();
27028 let secondary = WorkspaceConfig::new(
27029 WorkspaceId::new("secondary").unwrap(),
27030 &secondary_root,
27031 )
27032 .unwrap();
27033 let shared = NodeShared::new(
27034 handle,
27035 "fixture-token".to_owned(),
27036 NodeId::new("node-1").unwrap(),
27037 vec![primary, secondary.clone()],
27038 vec![agent("claude")],
27039 );
27040 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27041 shared
27042 .insert_record(ManagedSessionRecord {
27043 record_id: record_id.clone(),
27044 display_name: "workspace record".to_owned(),
27045 provider: agent("claude"),
27046 mode: SessionMode::Pty,
27047 state: ManagedSessionState::Dormant,
27048 workspace_id: secondary.workspace_id.clone(),
27049 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27050 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27051 key: gate4agent_types::ProviderSessionKey::SessionId,
27052 id: "provider-session".to_owned(),
27053 transcript_path: None,
27054 }),
27055 active_session: None,
27056 environment_profile: None,
27057 bundle: None,
27058 context_id: None,
27059 context: None,
27060 exported_context: None,
27061 task_binding: None,
27062 created_at_unix_ms: 1,
27063 updated_at_unix_ms: 1,
27064 last_error: None,
27065 })
27066 .unwrap();
27067 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27068 let unavailable = shared.record(&record_id).unwrap();
27069 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27070 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27071
27072 let conflict = shared
27073 .register_workspace(
27074 secondary.workspace_id.clone(),
27075 other_root.to_string_lossy().into_owned(),
27076 )
27077 .await
27078 .unwrap_err();
27079 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27080 shared
27081 .register_workspace(
27082 secondary.workspace_id,
27083 secondary_root.to_string_lossy().into_owned(),
27084 )
27085 .await
27086 .unwrap();
27087 let restored = shared.record(&record_id).unwrap();
27088 assert_eq!(restored.state, ManagedSessionState::Dormant);
27089 assert!(restored.last_error.is_none());
27090 std::fs::remove_dir_all(secondary_root).unwrap();
27091 std::fs::remove_dir_all(other_root).unwrap();
27092 }
27093
27094 #[test]
27095 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27096 let error = validate_workspace_request_root(
27097 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27098 )
27099 .unwrap_err();
27100 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27101 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27102 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27103 }
27104
27105 #[test]
27106 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27107 let catalog = active_registry().unwrap();
27108 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27109 let workspace = WorkspaceConfig::new(
27110 WorkspaceId::new("primary").unwrap(),
27111 std::env::current_dir().unwrap(),
27112 )
27113 .unwrap();
27114 let shared = NodeShared::new(
27115 handle,
27116 "fixture-token".to_owned(),
27117 NodeId::new("node-1").unwrap(),
27118 vec![workspace],
27119 vec![agent("claude")],
27120 );
27121 let publishers = 4_u64;
27122 let events_per_publisher = 256_u64;
27123 let start = std::sync::Barrier::new(publishers as usize + 1);
27124 let finished = AtomicU64::new(0);
27125
27126 std::thread::scope(|scope| {
27127 for _ in 0..publishers {
27128 scope.spawn(|| {
27129 start.wait();
27130 for _ in 0..events_per_publisher {
27131 shared.publish(NodeEvent::ControllerChanged { controller: None });
27132 }
27133 finished.fetch_add(1, Ordering::Release);
27134 });
27135 }
27136 start.wait();
27137 while finished.load(Ordering::Acquire) != publishers {
27138 assert_resync_history_is_linearized(shared.resync(0));
27139 std::thread::yield_now();
27140 }
27141 });
27142 let response = shared.resync(0);
27143 assert_resync_history_is_linearized(response.clone());
27144 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27145 unreachable!("resync helper returned another response");
27146 };
27147 assert_eq!(event_sequence, publishers * events_per_publisher);
27148 assert_eq!(events.len() as u64, event_sequence);
27149 }
27150
27151 fn assert_resync_history_is_linearized(response: NodeResponse) {
27152 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27153 unreachable!("resync helper returned another response");
27154 };
27155 assert!(events.windows(2).all(|pair| {
27156 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27157 }));
27158 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27159 }
27160
27161 #[test]
27162 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27163 assert_eq!(
27164 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27165 r"C:\repo\worktree",
27166 );
27167 assert_eq!(
27168 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27169 r"\\server\share\repo",
27170 );
27171 assert!(validate_workspace_root(
27172 &WorkspaceId::new("network").unwrap(),
27173 r"\\server\share\repo",
27174 )
27175 .is_err());
27176 let workspace = WorkspaceConfig::new(
27177 WorkspaceId::new("primary").unwrap(),
27178 std::env::current_dir().unwrap(),
27179 )
27180 .unwrap();
27181 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27182 }
27183
27184 #[test]
27185 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27186 let catalog = active_registry().unwrap();
27187 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27188 let workspace_id = WorkspaceId::new("primary").unwrap();
27189 let workspace = WorkspaceConfig::new(
27190 workspace_id.clone(),
27191 std::env::current_dir().unwrap(),
27192 )
27193 .unwrap();
27194 let shared = NodeShared::new(
27195 handle,
27196 "fixture-token".to_owned(),
27197 NodeId::new("node-1").unwrap(),
27198 vec![workspace],
27199 vec![agent("claude")],
27200 );
27201 let instance_id = AgentInstanceId(17);
27202 let original = SessionAddress {
27203 workspace_id,
27204 session: SessionKey {
27205 instance_id,
27206 generation: SessionGeneration(3),
27207 },
27208 };
27209 shared.bind_session(
27210 instance_id,
27211 original.workspace_id.clone(),
27212 original.session.generation,
27213 );
27214 shared
27215 .arm_resume(
27216 &original,
27217 CommandId(41),
27218 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27219 )
27220 .unwrap();
27221
27222 shared.publish_control(ControlEvent {
27223 sequence: 1,
27224 command_id: None,
27225 instance_id,
27226 generation: SessionGeneration(4),
27227 event: ControlEventKind::Running { process_id: None },
27228 });
27229 assert_eq!(
27230 shared.address_for(instance_id, SessionGeneration(3)),
27231 Some(original.clone()),
27232 );
27233 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27234
27235 shared.publish_control(ControlEvent {
27236 sequence: 2,
27237 command_id: Some(CommandId(42)),
27238 instance_id,
27239 generation: SessionGeneration(3),
27240 event: ControlEventKind::CommandRejected {
27241 message: "unrelated command".to_owned(),
27242 },
27243 });
27244
27245 shared.publish_control(ControlEvent {
27246 sequence: 3,
27247 command_id: None,
27248 instance_id,
27249 generation: SessionGeneration(4),
27250 event: ControlEventKind::ResumeAuthorized {
27251 session: gate4agent_types::ResumeSessionSummary {
27252 key: gate4agent_types::ProviderSessionKey::SessionId,
27253 id: "fixture-session".to_owned(),
27254 },
27255 },
27256 });
27257 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27258 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27259 assert_eq!(rebound.workspace_id, original.workspace_id);
27260 let history = shared
27261 .history
27262 .lock()
27263 .unwrap_or_else(|poisoned| poisoned.into_inner());
27264 assert!(history.events.iter().any(|envelope| matches!(
27265 &envelope.event,
27266 NodeEvent::Control { address, event }
27267 if address == &rebound
27268 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27269 )));
27270 }
27271
27272 #[test]
27273 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27274 for agent in ["claude", "codex", "kimi"] {
27275 assert_eq!(
27276 prompt_framing(&AgentId::new(agent).unwrap()),
27277 PromptFraming::BracketedPaste
27278 );
27279 }
27280 }
27281
27282 fn generous_walk_deadline() -> Instant {
27283 Instant::now() + Duration::from_secs(30)
27284 }
27285
27286 #[test]
27287 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27288 let root = temporary_workspace_root("tree");
27289 std::fs::create_dir_all(root.join("src")).unwrap();
27290 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27291 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27292 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27293 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27294 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27295 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27301 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27302 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27306 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27307
27308 let (entries, truncated, _budget) =
27309 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27310 assert!(!truncated);
27311 assert!(entries.iter().any(|entry| {
27312 entry.relative_path.as_utf8() == Some("src")
27313 && entry.kind == WorkspaceEntryKind::Directory
27314 }));
27315 assert!(entries.iter().any(|entry| {
27316 entry.relative_path.as_utf8() == Some("src/lib.rs")
27317 && entry.kind == WorkspaceEntryKind::File
27318 }));
27319 assert!(entries.iter().any(|entry| {
27320 entry.relative_path.as_utf8() == Some("target-notes")
27321 && entry.kind == WorkspaceEntryKind::Directory
27322 }));
27323 assert!(entries.iter().any(|entry| {
27324 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27325 && entry.kind == WorkspaceEntryKind::File
27326 }));
27327 assert!(entries.iter().all(|entry| {
27328 let path = entry.relative_path.as_utf8().unwrap();
27329 !path.starts_with(".git")
27330 && !(path == "target" || path.starts_with("target/"))
27331 && !(path == "target-a2" || path.starts_with("target-a2/"))
27332 && !path.starts_with("node_modules")
27333 }));
27334
27335 std::fs::remove_dir_all(&root).unwrap();
27336 }
27337
27338 #[test]
27339 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27340 let root = temporary_workspace_root("tree-entry-cap");
27341 std::fs::create_dir_all(&root).unwrap();
27342 for index in 0..20 {
27343 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27344 }
27345
27346 let (entries, truncated, budget) =
27347 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27348 assert!(truncated);
27349 assert!(budget.entry_cap_exceeded);
27350 assert!(!budget.time_budget_exceeded);
27351 assert!(entries.len() <= 5);
27352
27353 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27354 .expect("an entry-cap-truncated walk must report a truncation marker");
27355 assert!(inspection_truncation.walk_entry_cap_exceeded);
27356 assert!(!inspection_truncation.walk_time_budget_exceeded);
27357 assert!(!inspection_truncation.git_time_budget_exceeded);
27358 assert!(inspection_truncation.entries_visited >= 5);
27359
27360 std::fs::remove_dir_all(&root).unwrap();
27361 }
27362
27363 #[test]
27364 fn workspace_walk_within_budget_reports_no_truncation() {
27365 let root = temporary_workspace_root("tree-no-truncation");
27366 std::fs::create_dir_all(&root).unwrap();
27367 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27368
27369 let (_entries, truncated, budget) =
27370 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27371 assert!(!truncated);
27372 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27373
27374 std::fs::remove_dir_all(&root).unwrap();
27375 }
27376
27377 #[test]
27378 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27379 let root = temporary_workspace_root("tree-prefix-siblings");
27380 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27381 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27382 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27383 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27384
27385 let (entries, truncated, _budget) =
27386 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27387 assert!(!truncated);
27388 let paths = entries
27389 .iter()
27390 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27391 .collect::<Vec<_>>();
27392 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27393 assert_eq!(
27394 paths,
27395 vec![
27396 "gate4agent-c2".to_owned(),
27397 "gate4agent-c2-client".to_owned(),
27398 "gate4agent-c2-client/lib.rs".to_owned(),
27399 "gate4agent-c2/src".to_owned(),
27400 "gate4agent-c2/src/lib.rs".to_owned(),
27401 ],
27402 );
27403
27404 std::fs::remove_dir_all(&root).unwrap();
27405 }
27406
27407 #[test]
27408 fn git_parsers_cap_status_and_commit_results() {
27409 let mut status_output = Vec::new();
27410 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27411 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27412 }
27413 let mut snapshot = git_snapshot_for_parser();
27414 parse_git_status(&status_output, &mut snapshot);
27415 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27416 assert!(snapshot.truncated);
27417 assert_eq!(snapshot.status[0].index_status, " ");
27418 assert_eq!(snapshot.status[0].worktree_status, "M");
27419
27420 snapshot.truncated = false;
27421 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27422 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27423 .collect::<String>();
27424 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27425 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27426 assert!(snapshot.truncated);
27427 }
27428
27429 #[test]
27430 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27431 let mut snapshot = git_snapshot_for_parser();
27432 parse_git_status(
27433 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27434 &mut snapshot,
27435 );
27436
27437 assert!(!snapshot.truncated);
27438 assert_eq!(snapshot.status.len(), 3);
27439 assert_eq!(snapshot.status[0].index_status, "R");
27440 assert_eq!(snapshot.status[0].worktree_status, " ");
27441 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27442 assert_eq!(
27443 snapshot.status[0]
27444 .previous_path
27445 .as_ref()
27446 .and_then(RepositoryPath::as_utf8),
27447 Some("src/previous name.rs"),
27448 );
27449 assert_eq!(snapshot.status[1].index_status, " ");
27450 assert_eq!(snapshot.status[1].worktree_status, "C");
27451 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27452 assert_eq!(
27453 snapshot.status[1]
27454 .previous_path
27455 .as_ref()
27456 .and_then(RepositoryPath::as_utf8),
27457 Some("src/original.rs"),
27458 );
27459 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27460 assert!(snapshot.status[2].previous_path.is_none());
27461 }
27462
27463 #[test]
27464 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27465 let mut snapshot = git_snapshot_for_parser();
27466 parse_git_status(
27467 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27468 &mut snapshot,
27469 );
27470
27471 assert!(snapshot.truncated);
27472 assert_eq!(snapshot.status.len(), 1);
27473 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27474 assert!(snapshot.status[0].previous_path.is_none());
27475 }
27476
27477 #[tokio::test]
27478 async fn observer_can_browse_host_directories_without_controller() {
27479 let root = temporary_workspace_root("host-directory-observer");
27480 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27481 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27482 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27483 let workspace_id = WorkspaceId::new("primary").unwrap();
27484 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27485 let catalog = active_registry().unwrap();
27486 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27487 let shared = NodeShared::new(
27488 handle,
27489 "fixture-token".to_owned(),
27490 NodeId::new("node-1").unwrap(),
27491 vec![workspace],
27492 vec![agent("claude")],
27493 );
27494 let request = NodeRequest::BrowseHostDirectories {
27495 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27496 after: None,
27497 };
27498 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27499 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27500 }));
27501 assert!(!request_uses_unnegotiated_capability(
27502 &request,
27503 &baseline_capabilities().unwrap(),
27504 ));
27505 assert!(request_uses_unnegotiated_capability(&request, &[]));
27506
27507 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27508 .await
27509 .unwrap();
27510 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27511 panic!("host directory browse returned another response");
27512 };
27513 assert_eq!(
27514 listing
27515 .entries
27516 .iter()
27517 .map(|entry| entry.display_name.as_str())
27518 .collect::<Vec<_>>(),
27519 vec!["directory-a", "directory-b"],
27520 );
27521 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27522 assert!(!listing.incomplete);
27523 std::fs::remove_dir_all(root).unwrap();
27524 }
27525
27526 #[tokio::test]
27527 async fn observer_can_inspect_only_a_registered_workspace() {
27528 let root = temporary_workspace_root("observer");
27529 std::fs::create_dir_all(root.join("src")).unwrap();
27530 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27531 let workspace_id = WorkspaceId::new("primary").unwrap();
27532 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27533 let catalog = active_registry().unwrap();
27534 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27535 let shared = NodeShared::new(
27536 handle,
27537 "fixture-token".to_owned(),
27538 NodeId::new("node-1").unwrap(),
27539 vec![workspace],
27540 vec![agent("claude")],
27541 );
27542
27543 let response = process_request_inner(
27544 &shared,
27545 77,
27546 ClientRole::Observer,
27547 NodeRequest::InspectWorkspace {
27548 workspace_id: workspace_id.clone(),
27549 },
27550 )
27551 .await
27552 .unwrap();
27553 let NodeResponse::WorkspaceInspected { inspection } = response else {
27554 panic!("workspace inspection returned another response");
27555 };
27556 assert_eq!(inspection.workspace_id, workspace_id);
27557 assert!(inspection.entries.iter().any(|entry| {
27558 entry.relative_path.as_utf8() == Some("src/main.rs")
27559 }));
27560
27561 let unknown = process_request_inner(
27562 &shared,
27563 77,
27564 ClientRole::Observer,
27565 NodeRequest::InspectWorkspace {
27566 workspace_id: WorkspaceId::new("unknown").unwrap(),
27567 },
27568 )
27569 .await
27570 .unwrap_err();
27571 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27572 std::fs::remove_dir_all(&root).unwrap();
27573 }
27574
27575 #[test]
27576 fn context_pack_source_accepts_only_running_or_successful_exit() {
27577 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27578 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27579 exit_code: Some(0),
27580 }));
27581 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27582 exit_code: Some(1),
27583 }));
27584 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27585 exit_code: None,
27586 }));
27587 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27588 message: "provider failed".to_owned(),
27589 }));
27590 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27591 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27592 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27593 }
27594
27595 #[test]
27596 fn context_pack_repository_rejects_changed_head_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::new(),
27602 };
27603 let second = ContextPackRepositoryObservation {
27604 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27605 git,
27606 files: Vec::new(),
27607 };
27608
27609 let error = stable_context_pack_repository(first, second).unwrap_err();
27610
27611 assert_eq!(
27612 error.code,
27613 NodeFailureCode::ContextPackMaterializationFailed,
27614 );
27615 }
27616
27617 #[test]
27618 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27619 let git = git_snapshot_for_parser();
27620 let first = ContextPackRepositoryObservation {
27621 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27622 git: git.clone(),
27623 files: vec![ContextPackRepositoryFileSource::utf8(
27624 "README.md",
27625 "first\n".to_owned(),
27626 6,
27627 )],
27628 };
27629 let second = ContextPackRepositoryObservation {
27630 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27631 git,
27632 files: vec![ContextPackRepositoryFileSource::utf8(
27633 "README.md",
27634 "other\n".to_owned(),
27635 6,
27636 )],
27637 };
27638
27639 let error = stable_context_pack_repository(first, second).unwrap_err();
27640
27641 assert_eq!(
27642 error.code,
27643 NodeFailureCode::ContextPackMaterializationFailed,
27644 );
27645 }
27646
27647 #[tokio::test]
27648 async fn native_session_catalog_zero_active_session_fixture() {
27649 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27650 let history_root = temporary_workspace_root("native-catalog-history");
27651 let outside_root = temporary_workspace_root("native-catalog-outside");
27652 std::fs::create_dir_all(&workspace_root).unwrap();
27653 std::fs::create_dir_all(&history_root).unwrap();
27654 std::fs::create_dir_all(&outside_root).unwrap();
27655 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27656 [
27657 serde_json::json!({
27658 "type": "user",
27659 "sessionId": session_id,
27660 "cwd": cwd.to_str().unwrap(),
27661 "message": { "content": "raw user text must not cross the catalog wire" }
27662 }),
27663 serde_json::json!({
27664 "type": "assistant",
27665 "sessionId": session_id,
27666 "cwd": cwd.to_str().unwrap(),
27667 "message": { "model": "claude-sonnet", "content": "raw answer" }
27668 }),
27669 serde_json::json!({
27670 "type": "ai-title",
27671 "sessionId": session_id,
27672 "aiTitle": title
27673 }),
27674 ]
27675 .into_iter()
27676 .map(|record| record.to_string())
27677 .collect::<Vec<_>>()
27678 .join("\n")
27679 };
27680 std::fs::write(
27681 history_root.join("stable-session.jsonl"),
27682 transcript("stable-session", &workspace_root, "Workspace review"),
27683 )
27684 .unwrap();
27685 std::fs::write(
27686 history_root.join("outside-session.jsonl"),
27687 transcript("outside-session", &outside_root, "Outside review"),
27688 )
27689 .unwrap();
27690 let history = NativeHistoryConfig::new(vec![
27691 NativeHistoryRoot::new(
27692 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27693 HistorySourceLayout::SingleNdjson,
27694 history_root.clone(),
27695 )
27696 .unwrap(),
27697 ])
27698 .unwrap();
27699 let catalog = active_registry().unwrap();
27700 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27701 assert!(handle.snapshot().sessions.is_empty());
27702 let workspace_id = WorkspaceId::new("primary").unwrap();
27703 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27704 let mut shared = NodeShared::new(
27705 handle,
27706 "fixture-token".to_owned(),
27707 NodeId::new("node-native-catalog").unwrap(),
27708 vec![workspace],
27709 vec![agent("claude")],
27710 );
27711 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27712 NativeSessionCatalogAuthority::new(history),
27713 )));
27714 let route = NativeSessionCatalogRoute::workspace(
27715 workspace_id.clone(),
27716 agent("claude"),
27717 );
27718 let request = NodeRequest::CatalogNativeSessions {
27719 route: route.clone(),
27720 limit: 2,
27721 };
27722 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27723 .await
27724 .unwrap_err();
27725 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27726 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27727 .await
27728 .unwrap();
27729 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27730 panic!("native catalog returned a different response");
27731 };
27732 let summary = summary.expect("node must emit native session catalog summary metadata");
27733 assert_eq!(entries.len(), 1);
27734 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27735 assert_eq!(entries[0].external_group, None);
27736 assert_eq!(entries[0].record_id, None);
27737 assert_eq!(entries[0].title, None);
27738 assert_eq!(entries[0].model, None);
27739 assert_eq!(entries[0].message_count, 0);
27740 let encoded_entries = serde_json::to_string(&entries).unwrap();
27741 assert!(!encoded_entries.contains("stable-session"));
27742 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27743 let page_request = NodeRequest::PageNativeSessions {
27744 route: route.clone(),
27745 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27746 catalog_revision: summary.catalog_revision,
27747 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27748 after_selection_id: None,
27749 limit: 2,
27750 };
27751 let observer = process_request_inner(
27752 &shared,
27753 7,
27754 ClientRole::Observer,
27755 page_request.clone(),
27756 )
27757 .await
27758 .unwrap_err();
27759 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27760 let page_response = process_request_inner(
27761 &shared,
27762 7,
27763 ClientRole::Operator,
27764 page_request.clone(),
27765 )
27766 .await
27767 .unwrap();
27768 assert!(matches!(
27769 page_response,
27770 NodeResponse::NativeSessionsPaged { page, .. }
27771 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27772 ));
27773 let stale = process_request_inner(
27774 &shared,
27775 7,
27776 ClientRole::Operator,
27777 NodeRequest::PageNativeSessions {
27778 route: route.clone(),
27779 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27780 catalog_revision: summary.catalog_revision.wrapping_add(1),
27781 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27782 after_selection_id: None,
27783 limit: 2,
27784 },
27785 )
27786 .await
27787 .unwrap_err();
27788 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27789 let selection = NativeSessionSelection {
27790 route: route.clone(),
27791 catalog_revision: summary.catalog_revision,
27792 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27793 selection_id: entries[0].selection_id.clone(),
27794 };
27795 let preview_response = process_request_inner(
27796 &shared,
27797 7,
27798 ClientRole::Operator,
27799 NodeRequest::PreviewNativeSession {
27800 selection: selection.clone(),
27801 message_limit: 1,
27802 },
27803 )
27804 .await
27805 .unwrap();
27806 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27807 panic!("native preview returned a different response");
27808 };
27809 assert_eq!(preview.messages.len(), 1);
27810 assert!(preview.message_count_exact);
27811 assert_eq!(preview.messages[0].text, "raw answer");
27812 assert!(preview.truncated);
27813
27814 let index_request = NodeRequest::IndexNativeSession {
27815 selection: selection.clone(),
27816 display_name: "Saved Claude".to_owned(),
27817 };
27818 let observer = process_request_inner(
27819 &shared,
27820 7,
27821 ClientRole::Observer,
27822 index_request.clone(),
27823 )
27824 .await
27825 .unwrap_err();
27826 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27827 let without_controller = process_request_inner(
27828 &shared,
27829 7,
27830 ClientRole::Operator,
27831 index_request.clone(),
27832 )
27833 .await
27834 .unwrap_err();
27835 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27836 let acquired = process_request_inner(
27837 &shared,
27838 7,
27839 ClientRole::Operator,
27840 NodeRequest::AcquireController { lease_ms: 5_000 },
27841 )
27842 .await
27843 .unwrap();
27844 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27845 let indexed = process_request_inner(
27846 &shared,
27847 7,
27848 ClientRole::Operator,
27849 index_request,
27850 )
27851 .await
27852 .unwrap();
27853 assert!(indexed.native_session_index_contract_is_valid());
27854 let NodeResponse::NativeSessionIndexed {
27855 selection: echoed_selection,
27856 record,
27857 } = indexed else {
27858 panic!("native session index returned a different response");
27859 };
27860 assert_eq!(echoed_selection, selection);
27861 let recataloged = process_request_inner(
27862 &shared,
27863 7,
27864 ClientRole::Operator,
27865 NodeRequest::CatalogNativeSessions {
27866 route,
27867 limit: 2,
27868 },
27869 )
27870 .await
27871 .unwrap();
27872 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27873 panic!("native catalog returned a different response after indexing");
27874 };
27875 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27876
27877 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27878 let external_catalog = process_request_inner(
27879 &shared,
27880 7,
27881 ClientRole::Operator,
27882 NodeRequest::CatalogNativeSessions {
27883 route: unregistered_route.clone(),
27884 limit: 2,
27885 },
27886 )
27887 .await
27888 .unwrap();
27889 let external_json = serde_json::to_string(&external_catalog).unwrap();
27890 assert!(!external_json.contains("outside-session"));
27891 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27892 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27893 assert!(!external_json.contains("\"session_id\""));
27894 assert!(!external_json.contains("\"cwd\""));
27895 assert!(!external_json.contains("\"path\""));
27896 let NodeResponse::NativeSessionsCataloged {
27897 route: echoed_route,
27898 entries: external_entries,
27899 summary: Some(external_summary),
27900 } = external_catalog
27901 else {
27902 panic!("unregistered native catalog returned a different response");
27903 };
27904 assert_eq!(echoed_route, unregistered_route);
27905 assert_eq!(external_entries.len(), 1);
27906 assert_eq!(external_entries[0].record_id, None);
27907 let external_group = external_entries[0]
27908 .external_group
27909 .as_ref()
27910 .expect("unregistered session must have an external group");
27911 assert_eq!(
27912 external_group.kind,
27913 gate4agent_types::NativeSessionExternalGroupKind::Project,
27914 );
27915 assert!(external_group.group_id.starts_with("external-"));
27916 assert_eq!(
27917 external_group.display_name,
27918 outside_root.file_name().unwrap().to_string_lossy(),
27919 );
27920 let external_selection = NativeSessionSelection {
27921 route: unregistered_route,
27922 catalog_revision: external_summary.catalog_revision,
27923 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27924 selection_id: external_entries[0].selection_id.clone(),
27925 };
27926 let external_preview = process_request_inner(
27927 &shared,
27928 7,
27929 ClientRole::Operator,
27930 NodeRequest::PreviewNativeSession {
27931 selection: external_selection.clone(),
27932 message_limit: 1,
27933 },
27934 )
27935 .await
27936 .unwrap();
27937 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27938 assert!(!external_preview_json.contains("outside-session"));
27939 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27940 assert!(!external_preview_json.contains("\"session_id\""));
27941 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27942 panic!("unregistered native preview returned a different response");
27943 };
27944 assert_eq!(preview.messages.len(), 1);
27945 let external_index = process_request_inner(
27946 &shared,
27947 7,
27948 ClientRole::Operator,
27949 NodeRequest::IndexNativeSession {
27950 selection: external_selection,
27951 display_name: "External Claude".to_owned(),
27952 },
27953 )
27954 .await
27955 .unwrap_err();
27956 assert_eq!(
27957 external_index.code,
27958 NodeFailureCode::WorkspaceRegistrationRequired,
27959 );
27960 let record_response = process_request_inner(
27961 &shared,
27962 7,
27963 ClientRole::Operator,
27964 NodeRequest::PreviewSessionRecord {
27965 record_id: record.record_id.clone(),
27966 message_limit: 2,
27967 },
27968 )
27969 .await
27970 .unwrap();
27971 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27972 panic!("record preview returned a different response");
27973 };
27974 assert_eq!(record_id, record.record_id);
27975 assert_eq!(preview.messages.len(), 2);
27976 let encoded = serde_json::to_string(&preview).unwrap();
27977 assert!(!encoded.contains("stable-session"));
27978 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27979 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27980 panic!("record preview history summary returned a different response");
27981 };
27982 let summaries = events
27983 .iter()
27984 .filter_map(|envelope| match &envelope.event {
27985 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27986 if record_id == &record.record_id =>
27987 {
27988 Some(*summary)
27989 }
27990 _ => None,
27991 })
27992 .collect::<Vec<_>>();
27993 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27994 let summary_json = serde_json::to_string(&summaries).unwrap();
27995 for private in [
27996 "stable-session",
27997 "raw user text must not cross the catalog wire",
27998 "raw answer",
27999 "Workspace review",
28000 "claude-sonnet",
28001 ] {
28002 assert!(!summary_json.contains(private), "{private}");
28003 }
28004 let identity = record.provider_session.clone().unwrap();
28005 assert_eq!(
28006 shared.revalidate_session_record_preview(&record, &identity),
28007 Ok(())
28008 );
28009 shared.remove_record_memory(&record.record_id);
28010 assert_eq!(
28011 shared
28012 .revalidate_session_record_preview(&record, &identity)
28013 .unwrap_err()
28014 .code,
28015 NodeFailureCode::UnknownSessionRecord
28016 );
28017 let released = process_request_inner(
28018 &shared,
28019 7,
28020 ClientRole::Operator,
28021 NodeRequest::ReleaseController,
28022 )
28023 .await
28024 .unwrap();
28025 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28026 assert!(shared.controller_state().is_none());
28027 std::fs::remove_dir_all(workspace_root).unwrap();
28028 std::fs::remove_dir_all(history_root).unwrap();
28029 std::fs::remove_dir_all(outside_root).unwrap();
28030 }
28031
28032 #[tokio::test]
28033 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28034 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28035 std::fs::create_dir_all(&history_root).unwrap();
28036 let history = NativeHistoryConfig::new(vec![
28037 NativeHistoryRoot::new(
28038 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28039 HistorySourceLayout::SingleNdjson,
28040 history_root.clone(),
28041 )
28042 .unwrap(),
28043 ])
28044 .unwrap();
28045 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28046 let held = catalog.lock().unwrap();
28047 let result = timeout(
28048 Duration::from_secs(1),
28049 run_native_session_catalog_operation(
28050 Arc::clone(&catalog),
28051 "native session catalog",
28052 |_| Ok::<(), std::convert::Infallible>(()),
28053 ),
28054 )
28055 .await
28056 .expect("concurrent native catalog request must not wait for the operation deadline")
28057 .unwrap_err();
28058 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28059 drop(held);
28060 std::fs::remove_dir_all(history_root).unwrap();
28061 }
28062
28063 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28064 let unique = std::time::SystemTime::now()
28065 .duration_since(std::time::UNIX_EPOCH)
28066 .unwrap()
28067 .as_nanos();
28068 std::env::temp_dir().join(format!(
28069 "gate4agent-node-{label}-{}-{unique}",
28070 std::process::id(),
28071 ))
28072 }
28073
28074 fn git_snapshot_for_parser() -> GitSnapshot {
28075 GitSnapshot {
28076 is_repository: true,
28077 branch: Some("main".to_owned()),
28078 status: Vec::new(),
28079 recent_commits: Vec::new(),
28080 worktrees: Vec::new(),
28081 managed_worktree: None,
28082 truncated: false,
28083 diagnostic: None,
28084 }
28085 }
28086
28087 #[tokio::test]
28088 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28089 let root = temporary_workspace_root("managed-git-scope");
28090 std::fs::create_dir_all(&root).unwrap();
28091 run_test_git(&root, &["init"]);
28092 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28093 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28094 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28095 run_test_git(&root, &["add", "seed.txt"]);
28096 run_test_git(&root, &["commit", "-m", "seed"]);
28097 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28098
28099 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28100 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28101 let catalog = active_registry().unwrap();
28102 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28103 let shared = NodeShared::new(
28104 handle,
28105 "fixture-token".to_owned(),
28106 NodeId::new("node-1").unwrap(),
28107 vec![workspace],
28108 vec![agent("claude")],
28109 );
28110 let mut lease = ManagedWorktreeLeaseRecord {
28111 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28112 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28113 workspace_id: workspace_id.clone(),
28114 profile_id: WorktreeProfileId::new("default").unwrap(),
28115 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28116 target_root: root.to_string_lossy().into_owned(),
28117 branch: "gate4agent/a".to_owned(),
28118 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28119 expected_head: None,
28120 retention: ManagedWorktreeRetention::Retain,
28121 state: ManagedWorktreeLeaseState::InUse,
28122 session_holders: vec![ManagedWorktreeSessionHolder {
28123 incarnation_id: shared.incarnation_id,
28124 instance_id: AgentInstanceId(7),
28125 generation: SessionGeneration(1),
28126 }],
28127 record_holders: Vec::new(),
28128 cleanup_failure: None,
28129 created_at_unix_ms: 1,
28130 updated_at_unix_ms: 1,
28131 };
28132 *shared
28133 .managed_worktrees
28134 .lock()
28135 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28136 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28137
28138 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28139 let scope = inspection.git.managed_worktree.unwrap();
28140 assert_eq!(scope.lease_id, lease.lease_id);
28141 assert_eq!(scope.branch, "gate4agent/a");
28142 assert_eq!(scope.active_session_count, 1);
28143
28144 lease.branch = "gate4agent/b".to_owned();
28145 *shared
28146 .managed_worktrees
28147 .lock()
28148 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28149 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28150 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28151 assert!(mismatch.git.managed_worktree.is_none());
28152 std::fs::remove_dir_all(root).unwrap();
28153 }
28154
28155 #[tokio::test]
28156 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28157 let root = temporary_workspace_root("workspace-file-write-controller");
28158 std::fs::create_dir_all(root.join("src")).unwrap();
28159 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28160 let workspace_id = WorkspaceId::new("primary").unwrap();
28161 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28162 let catalog = active_registry().unwrap();
28163 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28164 let shared = NodeShared::new(
28165 handle,
28166 "fixture-token".to_owned(),
28167 NodeId::new("node-1").unwrap(),
28168 vec![workspace],
28169 vec![agent("claude")],
28170 );
28171 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28172 let expected_revision = workspace_file_revision(b"old\n");
28173 let request = NodeRequest::WriteWorkspaceFile {
28174 workspace_id: workspace_id.clone(),
28175 path: path.clone(),
28176 expected_revision: expected_revision.clone(),
28177 text: "new\n".to_owned(),
28178 };
28179 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28180 .await
28181 .unwrap_err();
28182 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28183 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28184 .await
28185 .unwrap_err();
28186 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28187 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28188 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28189 let NodeResponse::WorkspaceFileWritten { file } = response else {
28190 panic!("workspace file write returned another response");
28191 };
28192 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28193 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28194 let stale = process_request_inner(
28195 &shared,
28196 77,
28197 ClientRole::Operator,
28198 NodeRequest::WriteWorkspaceFile {
28199 workspace_id,
28200 path,
28201 expected_revision,
28202 text: "lost update\n".to_owned(),
28203 },
28204 )
28205 .await
28206 .unwrap_err();
28207 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28208 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28209 std::fs::remove_dir_all(root).unwrap();
28210 }
28211
28212 #[tokio::test]
28213 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28214 let root = temporary_workspace_root("workspace-entry-create-controller");
28215 std::fs::create_dir_all(root.join("src")).unwrap();
28216 let workspace_id = WorkspaceId::new("primary").unwrap();
28217 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28218 let catalog = active_registry().unwrap();
28219 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28220 let shared = NodeShared::new(
28221 handle,
28222 "fixture-token".to_owned(),
28223 NodeId::new("node-1").unwrap(),
28224 vec![workspace],
28225 vec![agent("claude")],
28226 );
28227 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28228 let request = NodeRequest::CreateWorkspaceFile {
28229 workspace_id: workspace_id.clone(),
28230 path: path.clone(),
28231 };
28232 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28233 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28234 }));
28235 assert!(!request_uses_unnegotiated_capability(
28236 &request,
28237 &baseline_capabilities().unwrap(),
28238 ));
28239 assert!(request_uses_unnegotiated_capability(&request, &[]));
28240 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28241 .await
28242 .unwrap_err();
28243 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28244 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28245 .await
28246 .unwrap_err();
28247 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28248 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28249
28250 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28251 .await
28252 .unwrap();
28253 let NodeResponse::WorkspaceFileCreated { file } = response else {
28254 panic!("workspace file create returned another response");
28255 };
28256 assert_eq!(file.workspace_id, workspace_id);
28257 assert_eq!(file.path, path);
28258 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28259 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28260
28261 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28262 .await
28263 .unwrap_err();
28264 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28265
28266 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28267 let response = process_request_inner(
28268 &shared,
28269 77,
28270 ClientRole::Operator,
28271 NodeRequest::CreateWorkspaceDirectory {
28272 workspace_id: workspace_id.clone(),
28273 path: directory_path.clone(),
28274 },
28275 )
28276 .await
28277 .unwrap();
28278 assert_eq!(
28279 response,
28280 NodeResponse::WorkspaceDirectoryCreated {
28281 workspace_id: workspace_id.clone(),
28282 entry: WorkspaceEntry {
28283 relative_path: directory_path,
28284 kind: WorkspaceEntryKind::Directory,
28285 },
28286 },
28287 );
28288 assert!(root.join("src/nested").is_dir());
28289
28290 let missing_parent = process_request_inner(
28291 &shared,
28292 77,
28293 ClientRole::Operator,
28294 NodeRequest::CreateWorkspaceFile {
28295 workspace_id: workspace_id.clone(),
28296 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28297 },
28298 )
28299 .await
28300 .unwrap_err();
28301 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28302 let non_directory_parent = process_request_inner(
28303 &shared,
28304 77,
28305 ClientRole::Operator,
28306 NodeRequest::CreateWorkspaceFile {
28307 workspace_id,
28308 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28309 },
28310 )
28311 .await
28312 .unwrap_err();
28313 assert_eq!(
28314 non_directory_parent.code,
28315 NodeFailureCode::RepositoryParentNotDirectory,
28316 );
28317 std::fs::remove_dir_all(root).unwrap();
28318 }
28319
28320 #[tokio::test]
28321 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28322 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28323 std::fs::create_dir_all(root.join("src")).unwrap();
28324 #[cfg(windows)]
28325 let root = std::fs::canonicalize(root).unwrap();
28326 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28327 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28328 let barrier = Arc::new(std::sync::Barrier::new(2));
28329 let done = Arc::new(AtomicBool::new(false));
28330 let task_commit_state = Arc::clone(&commit_state);
28331 let task_barrier = Arc::clone(&barrier);
28332 let task_done = Arc::clone(&done);
28333 let task_root = root.clone();
28334 let task = tokio::task::spawn_blocking(move || {
28335 task_barrier.wait();
28336 let result = create_workspace_file_on_disk(
28337 &task_root,
28338 &path,
28339 &task_commit_state,
28340 );
28341 task_done.store(true, Ordering::Release);
28342 result
28343 });
28344
28345 let error = settle_workspace_entry_create(
28346 task,
28347 Arc::clone(&commit_state),
28348 Duration::from_millis(10),
28349 "fixture deadline",
28350 "fixture task failed",
28351 )
28352 .await
28353 .unwrap_err();
28354 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28355 assert_eq!(
28356 commit_state.load(Ordering::Acquire),
28357 WORKSPACE_ENTRY_CREATE_CANCELED,
28358 );
28359
28360 barrier.wait();
28361 timeout(Duration::from_secs(1), async {
28362 while !done.load(Ordering::Acquire) {
28363 tokio::task::yield_now().await;
28364 }
28365 })
28366 .await
28367 .expect("canceled blocking create did not settle");
28368 assert!(!root.join("src/late.rs").exists());
28369 std::fs::remove_dir_all(root).unwrap();
28370 }
28371}
28372
28373#[cfg(test)]
28374mod provisional_spawn_tests {
28375 use super::*;
28376 use std::sync::Arc;
28377
28378 fn shared() -> NodeShared {
28379 let catalog = active_registry().unwrap();
28380 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28381 let workspace = WorkspaceConfig::new(
28382 WorkspaceId::new("primary").unwrap(),
28383 std::env::current_dir().unwrap(),
28384 )
28385 .unwrap()
28386 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28387 NodeShared::new(
28388 handle,
28389 "fixture-token".to_owned(),
28390 NodeId::new("node-provisional-test").unwrap(),
28391 vec![workspace],
28392 vec![AgentId::new("claude").unwrap()],
28393 )
28394 }
28395
28396 #[test]
28399 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28400 let shared = shared();
28401 let ws = WorkspaceId::new("primary").unwrap();
28402 let mut ids = Vec::with_capacity(50);
28403 for i in 1u64..=50 {
28404 let id = AgentInstanceId(i);
28405 ids.push(id);
28406 let address = SessionAddress {
28407 workspace_id: ws.clone(),
28408 session: SessionKey {
28409 instance_id: id,
28410 generation: SessionGeneration(1),
28411 },
28412 };
28413 shared.mark_spawn_provisional(address);
28414 }
28415 assert_eq!(
28416 shared
28417 .provisional_spawns
28418 .lock()
28419 .unwrap_or_else(|p| p.into_inner())
28420 .len(),
28421 50
28422 );
28423 for id in ids.iter().step_by(2) {
28424 shared.sessionize_spawn(*id);
28425 }
28426 assert_eq!(
28427 shared
28428 .provisional_spawns
28429 .lock()
28430 .unwrap_or_else(|p| p.into_inner())
28431 .len(),
28432 25
28433 );
28434 assert!(shared.take_due_provisional_spawns().is_empty());
28435 {
28436 let mut map = shared
28437 .provisional_spawns
28438 .lock()
28439 .unwrap_or_else(|p| p.into_inner());
28440 let past = Instant::now() - Duration::from_secs(91);
28441 for (_, (_, at)) in map.iter_mut() {
28442 *at = past;
28443 }
28444 }
28445 let due = shared.take_due_provisional_spawns();
28446 assert_eq!(due.len(), 25);
28447 let due_ids: std::collections::BTreeSet<_> =
28448 due.iter().map(|a| a.session.instance_id.0).collect();
28449 let expected: std::collections::BTreeSet<_> =
28450 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28451 assert_eq!(due_ids, expected);
28452 assert!(shared
28453 .provisional_spawns
28454 .lock()
28455 .unwrap_or_else(|p| p.into_inner())
28456 .is_empty());
28457 }
28458
28459 #[test]
28462 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28463 let shared = Arc::new(shared());
28464 let ws = WorkspaceId::new("primary").unwrap();
28465 let mut handles = Vec::new();
28466 for i in 1u64..=20 {
28467 let shared = Arc::clone(&shared);
28468 let ws = ws.clone();
28469 handles.push(std::thread::spawn(move || {
28470 let id = AgentInstanceId(i);
28471 let address = SessionAddress {
28472 workspace_id: ws,
28473 session: SessionKey {
28474 instance_id: id,
28475 generation: SessionGeneration(1),
28476 },
28477 };
28478 shared.mark_spawn_provisional(address);
28479 id
28480 }));
28481 }
28482 let mut ids = Vec::new();
28483 for h in handles {
28484 ids.push(h.join().unwrap());
28485 }
28486 ids.sort_by_key(|id| id.0);
28487 assert_eq!(ids.len(), 20);
28488 ids.dedup();
28489 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28490 assert_eq!(
28491 shared
28492 .provisional_spawns
28493 .lock()
28494 .unwrap_or_else(|p| p.into_inner())
28495 .len(),
28496 20
28497 );
28498 shared.sessionize_spawn(AgentInstanceId(7));
28499 let left: Vec<_> = shared
28500 .provisional_spawns
28501 .lock()
28502 .unwrap_or_else(|p| p.into_inner())
28503 .keys()
28504 .map(|id| id.0)
28505 .collect();
28506 assert!(!left.contains(&7));
28507 assert_eq!(left.len(), 19);
28508 }
28509}
28510
28511
28512#[cfg(unix)]
28513extern "C" {
28514 #[link_name = "geteuid"]
28515 fn libc_geteuid() -> u32;
28516}
28517
28518#[cfg(test)]
28519mod call_home_link_tests {
28520 use super::*;
28521
28522 #[test]
28523 fn call_home_backoff_is_capped_and_monotonic() {
28524 let mut last = Duration::ZERO;
28525 for attempt in 1..20 {
28526 let next = call_home_backoff(attempt);
28527 assert!(next >= last);
28528 assert!(next <= Duration::from_secs(8));
28529 last = next;
28530 }
28531 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28532 }
28533
28534 #[tokio::test]
28535 async fn call_home_socket_gets_keepalive() {
28536 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28537 let addr = listener.local_addr().unwrap();
28538 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28539 let client = client.unwrap();
28540 enable_call_home_keepalive(&client).unwrap();
28541 let sock = socket2::SockRef::from(&client);
28542 assert!(sock.keepalive().unwrap());
28543 #[cfg(any(target_os = "linux", target_os = "macos"))]
28544 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28545 }
28546}
28547
28548fn provider_session_exists(provider: &str, id: &str) -> bool {
28550 if provider != "claude" { return false; }
28551 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28552 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28553 let name = format!("{id}.jsonl");
28554 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())))
28555}
28556
28557fn overlay_with_extra_args(
28559 provider: &AgentId,
28560 mode: SessionMode,
28561 overlay: Option<PreparedNativeLaunchOverlay>,
28562 args: Vec<OsString>,
28563) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28564 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28565 Ok(match (overlay, args) {
28566 (prepared, args) if args.is_empty() => prepared,
28567 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28568 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28569 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28570 provider.clone(),
28571 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28572 Vec::new(),
28573 args,
28574 ).map_err(|_| bad())?)),
28575 })
28576}