1use gate4agent_adapters::builtin_adapter_registry;
4use gate4agent_types::{
5 AcpTransportSpec, AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities,
6 AgentCommandMode, AgentId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities,
7 DetectionSpec, DraftReadySignal, InitialPromptMode, LaunchSpec, PipePromptDelivery,
8 PipeProtocol, PipeTransportSpec, ProcessMatcher, PromptSpec, SpecVerification,
9};
10use std::fmt;
11use std::path::{Component, Path, PathBuf};
12
13pub const CONTROL_FIXTURE_ID: &str = "control-fixture";
14pub const PIPE_FIXTURE_ID: &str = "pipe-fixture";
15pub const ONE_SHOT_FIXTURE_ID: &str = "one-shot-fixture";
16pub const ACP_FIXTURE_ID: &str = "acp-fixture";
17pub const GROK_ACP_FIXTURE_ID: &str = "grok";
20pub const PTY_PROVIDER_FIXTURE_ID: &str = "pty-provider-fixture";
21pub const HOOK_POSTING_FIXTURE_ID: &str = "hook-posting-fixture";
22pub const MONITORING_HOOK_FIXTURE_ID: &str = "monitoring-hook-fixture";
23pub const CLEAN_EXIT_FIXTURE_ID: &str = "clean-exit-fixture";
24pub const IDENTIFIED_CLEAN_EXIT_FIXTURE_ID: &str = "identified-clean-exit-fixture";
25pub const MONITORING_PROMPT_CANARY: &str = "g4a-private-prompt-canary";
26pub const MONITORING_TOOL_INPUT_CANARY: &str = "g4a-private-tool-input-canary";
27pub const MONITORING_TOOL_OUTPUT_CANARY: &str = "g4a-private-tool-output-canary";
28pub const MONITORING_PROVIDER_SESSION_CANARY: &str = "g4a-private-provider-session-canary";
29pub const MONITORING_SUBAGENT_ID_CANARY: &str = "g4a-private-subagent-id-canary";
30pub const MONITORING_SUBAGENT_DESCRIPTION_CANARY: &str =
31 "g4a-private-subagent-description-canary";
32pub const MONITORING_PERMISSION_INPUT_CANARY: &str =
33 "g4a-private-permission-input-canary";
34
35const FIXTURE_PATH_MAX_BYTES: usize = 4_096;
36
37#[derive(Clone, Debug, Eq, PartialEq)]
38pub enum ControlledExitFixtureError {
39 InvalidRoot,
40 InvalidTarget(&'static str),
41 TargetExists(&'static str),
42 DuplicateTargets,
43 InvalidSessionId,
44}
45
46impl fmt::Display for ControlledExitFixtureError {
47 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::InvalidRoot => formatter.write_str(
50 "controlled-exit fixture root must be an absolute real directory",
51 ),
52 Self::InvalidTarget(name) => write!(
53 formatter,
54 "controlled-exit fixture {name} must be a bounded absolute path inside the fixture root",
55 ),
56 Self::TargetExists(name) => write!(
57 formatter,
58 "controlled-exit fixture {name} must not already exist",
59 ),
60 Self::DuplicateTargets => formatter.write_str(
61 "controlled-exit fixture marker and release paths must differ",
62 ),
63 Self::InvalidSessionId => formatter.write_str(
64 "controlled-exit fixture session id must be a bounded, non-empty ASCII alphanumeric-or-hyphen string",
65 ),
66 }
67 }
68}
69
70impl std::error::Error for ControlledExitFixtureError {}
71
72pub fn suppress_windows_fault_dialogs_for_test() {
77 #[cfg(windows)]
78 unsafe {
79 const SEM_FAILCRITICALERRORS: u32 = 0x0001;
80 const SEM_NOGPFAULTERRORBOX: u32 = 0x0002;
81 const SEM_NOOPENFILEERRORBOX: u32 = 0x8000;
82 const WER_FAULT_REPORTING_NO_UI: u32 = 0x0020;
83
84 #[link(name = "kernel32")]
85 extern "system" {
86 fn SetErrorMode(mode: u32) -> u32;
87 fn WerSetFlags(flags: u32) -> i32;
88 }
89
90 SetErrorMode(
91 SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX,
92 );
93 let _ = WerSetFlags(WER_FAULT_REPORTING_NO_UI);
94 }
95}
96
97pub fn require_windows_headless_supervisor_for_test() {
102 #[cfg(windows)]
103 assert_eq!(
104 std::env::var_os("GATE4AGENT_HEADLESS_SUPERVISOR").as_deref(),
105 Some(std::ffi::OsStr::new("1")),
106 "Windows PTY tests must run through windows-headless-supervisor"
107 );
108}
109
110pub fn interactive_agent_spec() -> AgentSpec {
111 #[cfg(windows)]
112 let script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write([char]27 + '[?2004h' + [char]27 + '[?25hfixture-ready>'); $line=[Console]::ReadLine(); [Console]::Write('fixture-echo:' + $line); Start-Sleep -Seconds 60";
113 #[cfg(not(windows))]
114 let script = r#"printf '\033[?2004h\033[?25hfixture-ready>'; IFS= read -r line; printf 'fixture-echo:%s' "$line"; sleep 60"#;
115 fixture_spec(script)
116}
117
118pub fn exiting_agent_spec() -> AgentSpec {
119 #[cfg(windows)]
120 let script =
121 "[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Write('fixture-exit'); exit 7";
122 #[cfg(not(windows))]
123 let script = "printf 'fixture-exit'; exit 7";
124 fixture_spec(script)
125}
126
127pub fn controlled_clean_exit_agent_spec(
128 fixture_root: &Path,
129 started_marker: &Path,
130 release_signal: &Path,
131) -> Result<AgentSpec, ControlledExitFixtureError> {
132 validate_fixture_root(fixture_root)?;
133 let started_marker = validate_fixture_target(
134 fixture_root,
135 started_marker,
136 "started marker",
137 )?;
138 let release_signal = validate_fixture_target(
139 fixture_root,
140 release_signal,
141 "release signal",
142 )?;
143 if started_marker == release_signal {
144 return Err(ControlledExitFixtureError::DuplicateTargets);
145 }
146
147 #[cfg(windows)]
148 let launch = LaunchSpec {
149 program: "powershell.exe".to_owned(),
150 fixed_args: vec![
151 "-NoLogo".to_owned(),
152 "-NoProfile".to_owned(),
153 "-NonInteractive".to_owned(),
154 "-ExecutionPolicy".to_owned(),
155 "Bypass".to_owned(),
156 "-Command".to_owned(),
157 r#"& { param([string]$startedMarker, [string]$releaseSignal)
158$ErrorActionPreference = 'Stop'
159$bytes = [Text.Encoding]::UTF8.GetBytes("started`n")
160$marker = [IO.File]::Open($startedMarker, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
161try { $marker.Write($bytes, 0, $bytes.Length) } finally { $marker.Dispose() }
162while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {
163 Start-Sleep -Milliseconds 25
164}
165$release = Get-Item -LiteralPath $releaseSignal -Force
166if (($release -isnot [IO.FileInfo]) -or (($release.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 81 }
167exit 0
168}"#.to_owned(),
169 started_marker,
170 release_signal,
171 ],
172 };
173
174 #[cfg(not(windows))]
175 let launch = LaunchSpec {
176 program: "python3".to_owned(),
177 fixed_args: vec![
178 "-u".to_owned(),
179 "-c".to_owned(),
180 r#"import os,stat,sys,time
181marker=sys.argv[1]
182release=sys.argv[2]
183fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
184try:
185 os.write(fd,b'started\n')
186finally:
187 os.close(fd)
188while True:
189 try:
190 mode=os.lstat(release).st_mode
191 except FileNotFoundError:
192 time.sleep(0.025)
193 continue
194 if not stat.S_ISREG(mode):
195 sys.exit(81)
196 sys.exit(0)"#.to_owned(),
197 started_marker,
198 release_signal,
199 ],
200 };
201
202 Ok(provider_spec(
203 CLEAN_EXIT_FIXTURE_ID,
204 "Controlled clean-exit fixture",
205 launch,
206 AgentTransportCapabilities {
207 pty: true,
208 pty_adapter: None,
209 pipe: None,
210 acp: None,
211 },
212 ))
213}
214
215pub fn identified_clean_exit_agent_spec(
228 fixture_root: &Path,
229 started_marker: &Path,
230 release_signal: &Path,
231 session_id: &str,
232) -> Result<AgentSpec, ControlledExitFixtureError> {
233 validate_fixture_root(fixture_root)?;
234 let started_marker = validate_fixture_target(
235 fixture_root,
236 started_marker,
237 "started marker",
238 )?;
239 let release_signal = validate_fixture_target(
240 fixture_root,
241 release_signal,
242 "release signal",
243 )?;
244 if started_marker == release_signal {
245 return Err(ControlledExitFixtureError::DuplicateTargets);
246 }
247 if session_id.is_empty()
248 || session_id.len() > FIXTURE_PATH_MAX_BYTES
249 || !session_id.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
250 {
251 return Err(ControlledExitFixtureError::InvalidSessionId);
252 }
253
254 #[cfg(windows)]
255 let launch = LaunchSpec {
256 program: "powershell.exe".to_owned(),
257 fixed_args: vec![
258 "-NoLogo".to_owned(),
259 "-NoProfile".to_owned(),
260 "-NonInteractive".to_owned(),
261 "-ExecutionPolicy".to_owned(),
262 "Bypass".to_owned(),
263 "-Command".to_owned(),
264 r#"& { param([string]$startedMarker, [string]$releaseSignal, [string]$sessionId)
265$ErrorActionPreference = 'Stop'
266$headers = @{'X-Gate4Agent-Hook-Token' = $env:GATE4AGENT_HOOK_TOKEN; 'X-Gate4Agent-Hook-Route' = $env:GATE4AGENT_HOOK_ROUTE}
267$body = @{
268 hook_event_name = 'SessionStart'
269 event_id = 'identified-clean-exit-session-start'
270 payload = @{ hook_event_name = 'SessionStart'; session_id = $sessionId; model = 'fixture-model' }
271} | ConvertTo-Json -Compress -Depth 12
272Invoke-WebRequest -UseBasicParsing -Method Post -Uri $env:GATE4AGENT_HOOK_URL -Headers $headers -ContentType 'application/json' -Body $body | Out-Null
273$bytes = [Text.Encoding]::UTF8.GetBytes("started`n")
274$marker = [IO.File]::Open($startedMarker, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::Read)
275try { $marker.Write($bytes, 0, $bytes.Length) } finally { $marker.Dispose() }
276while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {
277 Start-Sleep -Milliseconds 25
278}
279$release = Get-Item -LiteralPath $releaseSignal -Force
280if (($release -isnot [IO.FileInfo]) -or (($release.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { exit 81 }
281exit 0
282}"#.to_owned(),
283 started_marker,
284 release_signal,
285 session_id.to_owned(),
286 ],
287 };
288
289 #[cfg(not(windows))]
290 let launch = LaunchSpec {
291 program: "python3".to_owned(),
292 fixed_args: vec![
293 "-u".to_owned(),
294 "-c".to_owned(),
295 r#"import json,os,stat,sys,time,urllib.request
296marker=sys.argv[1]
297release=sys.argv[2]
298session_id=sys.argv[3]
299body=json.dumps({"hook_event_name":"SessionStart","event_id":"identified-clean-exit-session-start","payload":{"hook_event_name":"SessionStart","session_id":session_id,"model":"fixture-model"}}).encode()
300request=urllib.request.Request(os.environ["GATE4AGENT_HOOK_URL"],data=body,headers={"Content-Type":"application/json","X-Gate4Agent-Hook-Token":os.environ["GATE4AGENT_HOOK_TOKEN"],"X-Gate4Agent-Hook-Route":os.environ["GATE4AGENT_HOOK_ROUTE"]},method="POST")
301urllib.request.urlopen(request,timeout=5).read()
302fd=os.open(marker,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
303try:
304 os.write(fd,b'started\n')
305finally:
306 os.close(fd)
307while True:
308 try:
309 mode=os.lstat(release).st_mode
310 except FileNotFoundError:
311 time.sleep(0.025)
312 continue
313 if not stat.S_ISREG(mode):
314 sys.exit(81)
315 sys.exit(0)"#.to_owned(),
316 started_marker,
317 release_signal,
318 session_id.to_owned(),
319 ],
320 };
321
322 let spec = provider_spec(
323 IDENTIFIED_CLEAN_EXIT_FIXTURE_ID,
324 "Controlled identified clean-exit fixture",
325 launch,
326 AgentTransportCapabilities {
327 pty: true,
328 pty_adapter: None,
329 pipe: None,
330 acp: None,
331 },
332 );
333 Ok(spec)
334}
335
336pub fn pipe_agent_spec() -> AgentSpec {
337 #[cfg(windows)]
338 let script = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::WriteLine('{"type":"thread.started","thread_id":"fixture-thread"}'); [Console]::WriteLine('{"type":"item.completed","item":{"id":"message-1","type":"agent_message","text":"fixture-pipe-response"}}'); [Console]::WriteLine('{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":5}}')"#;
339 #[cfg(not(windows))]
340 let script = r#"printf '%s\n' '{"type":"thread.started","thread_id":"fixture-thread"}' '{"type":"item.completed","item":{"id":"message-1","type":"agent_message","text":"fixture-pipe-response"}}' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens":5}}'"#;
341 let launch = provider_launch(script);
342 provider_spec(
343 PIPE_FIXTURE_ID,
344 "Control-plane Pipe fixture",
345 launch.clone(),
346 AgentTransportCapabilities {
347 pty: false,
348 pty_adapter: None,
349 pipe: Some(PipeTransportSpec {
350 adapter: adapter(AdapterFamily::Pipe, "codex"),
351 protocol: PipeProtocol::SemanticNdjson,
352 launch_override: Some(launch),
353 prompt_delivery: PipePromptDelivery::None,
354 }),
355 acp: None,
356 },
357 )
358}
359
360pub fn one_shot_agent_spec() -> AgentSpec {
361 #[cfg(windows)]
362 let script =
363 "$prompt=[Console]::In.ReadToEnd(); [Console]::Write('fixture-one-shot:' + $prompt)";
364 #[cfg(not(windows))]
365 let script = "prompt=$(cat); printf 'fixture-one-shot:%s' \"$prompt\"";
366 let launch = provider_launch(script);
367 let binding = adapter(AdapterFamily::OneShot, "claude");
368 let mut spec = provider_spec(
369 ONE_SHOT_FIXTURE_ID,
370 "Control-plane one-shot fixture",
371 launch.clone(),
372 AgentTransportCapabilities {
373 pty: false,
374 pty_adapter: None,
375 pipe: Some(PipeTransportSpec {
376 adapter: binding.clone(),
377 protocol: PipeProtocol::OneShotText,
378 launch_override: Some(launch),
379 prompt_delivery: PipePromptDelivery::None,
380 }),
381 acp: None,
382 },
383 );
384 spec.capabilities.adapters.one_shot = Some(binding);
385 spec
386}
387
388fn acp_fixture_launch() -> LaunchSpec {
392 #[cfg(windows)]
393 let script = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
394function Write-JsonLine($value) {
395 [Console]::WriteLine(($value | ConvertTo-Json -Compress -Depth 12))
396}
397
398$initialize = [Console]::ReadLine() | ConvertFrom-Json
399if ($initialize.method -ne 'initialize') {
400 Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;error=@{code=-32003;message='fixture expected initialize first'}}
401 exit 41
402}
403Write-JsonLine @{jsonrpc='2.0';id=$initialize.id;result=@{protocolVersion=1;agentCapabilities=@{loadSession=$false};agentInfo=@{name='fixture';title='Fixture ACP';version='1'}}}
404
405$newSession = [Console]::ReadLine() | ConvertFrom-Json
406$newSessionOk = ($newSession.method -eq 'session/new') -and
407 ($newSession.params.PSObject.Properties.Name -contains 'mcpServers') -and
408 (@($newSession.params.mcpServers).Count -eq 0)
409if (-not $newSessionOk) {
410 Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;error=@{code=-32003;message='fixture expected an empty MCP server list'}}
411 exit 42
412}
413Write-JsonLine @{jsonrpc='2.0';id=$newSession.id;result=@{sessionId='fixture-acp-session'}}
414
415while ($true) {
416 $line = [Console]::ReadLine()
417 if ($null -eq $line) { break }
418 $request = $line | ConvertFrom-Json
419 if ($request.method -ne 'session/prompt') { continue }
420
421 # Real ACP wire shapes throughout -- the host's own HostPolicy decides
422 # whether each of these is granted or denied; this fixture does not
423 # gate on the outcome (see gate4agent-shell-native's acp_fail_closed
424 # test, which asserts `decision` from the host's own broadcast instead).
425 Write-JsonLine @{jsonrpc='2.0';id=9101;method='fs/read_text_file';params=@{sessionId='fixture-acp-session';path='fixture-forbidden.txt'}}
426 $null = [Console]::ReadLine()
427
428 Write-JsonLine @{jsonrpc='2.0';id=9102;method='terminal/create';params=@{sessionId='fixture-acp-session';command='cmd';args=@('/C','exit','0')}}
429 $terminalResponse = [Console]::ReadLine() | ConvertFrom-Json
430 if ($null -eq $terminalResponse.error) {
431 Write-JsonLine @{jsonrpc='2.0';id=9103;method='terminal/release';params=@{sessionId='fixture-acp-session';terminalId=$terminalResponse.result.terminalId}}
432 $null = [Console]::ReadLine()
433 }
434
435 Write-JsonLine @{jsonrpc='2.0';id=9104;method='session/request_permission';params=@{
436 sessionId='fixture-acp-session'
437 toolCall=@{toolCallId='fixture-tool-call';title='fixture permission';kind='execute';locations=@()}
438 options=@(
439 @{optionId='allow-once';name='Allow once';kind='allow_once'}
440 @{optionId='allow-always';name='Allow always';kind='allow_always'}
441 @{optionId='reject-once';name='Reject once';kind='reject_once'}
442 )
443 }}
444 $null = [Console]::ReadLine()
445
446 Write-JsonLine @{jsonrpc='2.0';method='session/update';params=@{sessionId='fixture-acp-session';update=@{sessionUpdate='agent_message_chunk';content=@{type='text';text='fixture-acp-response'}}}}
447 Write-JsonLine @{jsonrpc='2.0';id=$request.id;result=@{stopReason='end_turn';inputTokens=7;outputTokens=11}}
448}"#;
449 #[cfg(not(windows))]
450 let script = r#"import json,sys
451def read_message():
452 message=sys.stdin.readline()
453 if not message: sys.exit(0)
454 return json.loads(message)
455def write_message(message):
456 print(json.dumps(message),flush=True)
457def fail(request,message,code):
458 write_message({'jsonrpc':'2.0','id':request.get('id'),'error':{'code':-32003,'message':message}})
459 sys.exit(code)
460
461initialize=read_message()
462if initialize.get('method')!='initialize':
463 fail(initialize,'fixture expected initialize first',41)
464write_message({'jsonrpc':'2.0','id':initialize.get('id'),'result':{'protocolVersion':1,'agentCapabilities':{'loadSession':False},'agentInfo':{'name':'fixture','title':'Fixture ACP','version':'1'}}})
465
466new_session=read_message()
467new_params=new_session.get('params',{})
468if new_session.get('method')!='session/new' or new_params.get('mcpServers')!=[]:
469 fail(new_session,'fixture expected an empty MCP server list',42)
470write_message({'jsonrpc':'2.0','id':new_session.get('id'),'result':{'sessionId':'fixture-acp-session'}})
471
472while True:
473 request=read_message()
474 if request.get('method')!='session/prompt': continue
475
476 # Real ACP wire shapes throughout -- the host's own HostPolicy decides
477 # whether each of these is granted or denied; this fixture does not gate
478 # on the outcome (see gate4agent-shell-native's acp_fail_closed test,
479 # which asserts `decision` from the host's own broadcast instead).
480 write_message({'jsonrpc':'2.0','id':9101,'method':'fs/read_text_file','params':{'sessionId':'fixture-acp-session','path':'fixture-forbidden.txt'}})
481 read_message()
482
483 write_message({'jsonrpc':'2.0','id':9102,'method':'terminal/create','params':{'sessionId':'fixture-acp-session','command':'true','args':[]}})
484 terminal_response=read_message()
485 if terminal_response.get('error') is None:
486 terminal_id=terminal_response.get('result',{}).get('terminalId')
487 write_message({'jsonrpc':'2.0','id':9103,'method':'terminal/release','params':{'sessionId':'fixture-acp-session','terminalId':terminal_id}})
488 read_message()
489
490 write_message({'jsonrpc':'2.0','id':9104,'method':'session/request_permission','params':{
491 'sessionId':'fixture-acp-session',
492 'toolCall':{'toolCallId':'fixture-tool-call','title':'fixture permission','kind':'execute','locations':[]},
493 'options':[
494 {'optionId':'allow-once','name':'Allow once','kind':'allow_once'},
495 {'optionId':'allow-always','name':'Allow always','kind':'allow_always'},
496 {'optionId':'reject-once','name':'Reject once','kind':'reject_once'},
497 ],
498 }})
499 read_message()
500
501 write_message({'jsonrpc':'2.0','method':'session/update','params':{'sessionId':'fixture-acp-session','update':{'sessionUpdate':'agent_message_chunk','content':{'type':'text','text':'fixture-acp-response'}}}})
502 write_message({'jsonrpc':'2.0','id':request.get('id'),'result':{'stopReason':'end_turn','inputTokens':7,'outputTokens':11}})"#;
503 #[cfg(windows)]
504 let launch = provider_launch(script);
505 #[cfg(not(windows))]
506 let launch = LaunchSpec {
507 program: "python3".to_owned(),
508 fixed_args: vec!["-u".to_owned(), "-c".to_owned(), script.to_owned()],
509 };
510 launch
511}
512
513pub fn acp_agent_spec() -> AgentSpec {
514 let launch = acp_fixture_launch();
515 provider_spec(
516 ACP_FIXTURE_ID,
517 "Control-plane ACP fixture",
518 launch.clone(),
519 AgentTransportCapabilities {
520 pty: false,
521 pty_adapter: None,
522 pipe: None,
523 acp: Some(AcpTransportSpec {
524 adapter: adapter(AdapterFamily::Acp, "claude-code"),
525 launch_override: Some(launch),
526 }),
527 },
528 )
529}
530
531pub fn grok_acp_agent_spec() -> AgentSpec {
539 let launch = acp_fixture_launch();
540 provider_spec(
541 GROK_ACP_FIXTURE_ID,
542 "Control-plane Grok ACP fixture",
543 launch.clone(),
544 AgentTransportCapabilities {
545 pty: false,
546 pty_adapter: None,
547 pipe: None,
548 acp: Some(AcpTransportSpec {
549 adapter: adapter(AdapterFamily::Acp, "grok"),
550 launch_override: Some(launch),
551 }),
552 },
553 )
554}
555
556pub fn pty_provider_agent_spec() -> AgentSpec {
557 #[cfg(windows)]
558 let script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::WriteLine([char]0x2022 + ' fixture-pty-response'); [Console]::WriteLine([char]0x203A); Start-Sleep -Seconds 60";
559 #[cfg(not(windows))]
560 let script = "printf '• fixture-pty-response\\n›\\n'; sleep 60";
561 let launch = provider_launch(script);
562 provider_spec(
563 PTY_PROVIDER_FIXTURE_ID,
564 "Control-plane PTY provider fixture",
565 launch,
566 AgentTransportCapabilities {
567 pty: true,
568 pty_adapter: Some(adapter(AdapterFamily::PtySemantic, "codex")),
569 pipe: None,
570 acp: None,
571 },
572 )
573}
574
575pub fn hook_posting_agent_spec() -> AgentSpec {
581 #[cfg(windows)]
582 let script = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8; $headers=@{'X-Gate4Agent-Hook-Token'=$env:GATE4AGENT_HOOK_TOKEN;'X-Gate4Agent-Hook-Route'=$env:GATE4AGENT_HOOK_ROUTE}; $body='{"hook_event_name":"UserPromptSubmit","event_id":"fixture-hook-1","payload":{"hook_event_name":"UserPromptSubmit","prompt":"fixture hook prompt"}}'; Invoke-WebRequest -UseBasicParsing -Method Post -Uri $env:GATE4AGENT_HOOK_URL -Headers $headers -ContentType 'application/json' -Body $body | Out-Null; [Console]::Write('fixture-hook-posted'); Start-Sleep -Seconds 60"#;
583 #[cfg(not(windows))]
584 let script = r#"python3 -c 'import json,os,urllib.request; body=json.dumps({"hook_event_name":"UserPromptSubmit","event_id":"fixture-hook-1","payload":{"hook_event_name":"UserPromptSubmit","prompt":"fixture hook prompt"}}).encode(); request=urllib.request.Request(os.environ["GATE4AGENT_HOOK_URL"],data=body,headers={"Content-Type":"application/json","X-Gate4Agent-Hook-Token":os.environ["GATE4AGENT_HOOK_TOKEN"],"X-Gate4Agent-Hook-Route":os.environ["GATE4AGENT_HOOK_ROUTE"]},method="POST"); urllib.request.urlopen(request,timeout=2).read()'; printf 'fixture-hook-posted'; sleep 60"#;
585 let launch = provider_launch(script);
586 let spec = provider_spec(
587 HOOK_POSTING_FIXTURE_ID,
588 "Control-plane Hook posting fixture",
589 launch,
590 AgentTransportCapabilities {
591 pty: true,
592 pty_adapter: None,
593 pipe: None,
594 acp: None,
595 },
596 );
597 spec
598}
599
600pub fn monitoring_hook_agent_spec() -> AgentSpec {
607 #[cfg(windows)]
608 let script = r#"[Console]::OutputEncoding=[Text.Encoding]::UTF8
609$headers = @{
610 'X-Gate4Agent-Hook-Token' = $env:GATE4AGENT_HOOK_TOKEN
611 'X-Gate4Agent-Hook-Route' = $env:GATE4AGENT_HOOK_ROUTE
612}
613function Send-FixtureHook([string]$eventName, [string]$eventId, [hashtable]$payload) {
614 $payload['hook_event_name'] = $eventName
615 $body = @{
616 hook_event_name = $eventName
617 event_id = $eventId
618 payload = $payload
619 } | ConvertTo-Json -Compress -Depth 12
620 Invoke-WebRequest -UseBasicParsing -Method Post -Uri $env:GATE4AGENT_HOOK_URL -Headers $headers -ContentType 'application/json' -Body $body | Out-Null
621}
622Send-FixtureHook 'SessionStart' 'monitoring-hook-1' @{
623 session_id = 'g4a-private-provider-session-canary'
624 model = 'fixture-model'
625}
626Send-FixtureHook 'UserPromptSubmit' 'monitoring-hook-2' @{
627 prompt = 'g4a-private-prompt-canary'
628}
629Send-FixtureHook 'PreToolUse' 'monitoring-hook-3' @{
630 tool_name = 'PowerShell'
631 tool_use_id = 'fixture-tool-1'
632 tool_input = @{ command = 'g4a-private-tool-input-canary' }
633}
634Send-FixtureHook 'PostToolUse' 'monitoring-hook-4' @{
635 tool_name = 'PowerShell'
636 tool_use_id = 'fixture-tool-1'
637 tool_response = @{ stdout = 'g4a-private-tool-output-canary' }
638 duration_ms = 17
639}
640Send-FixtureHook 'SubagentStart' 'monitoring-hook-5' @{
641 agent_id = 'g4a-private-subagent-id-canary'
642 agent_type = 'research-agent'
643 description = 'g4a-private-subagent-description-canary'
644}
645Send-FixtureHook 'SubagentStop' 'monitoring-hook-6' @{
646 agent_id = 'g4a-private-subagent-id-canary'
647}
648Send-FixtureHook 'PermissionRequest' 'monitoring-hook-7' @{
649 tool_name = 'PowerShell'
650 tool_use_id = 'fixture-permission-1'
651 tool_input = @{ command = 'g4a-private-permission-input-canary' }
652}
653Send-FixtureHook 'Stop' 'monitoring-hook-8' @{
654 last_assistant_message = 'fixture monitoring complete'
655}
656[Console]::Write('fixture-monitoring-hooks-posted')
657Start-Sleep -Seconds 60"#;
658 #[cfg(not(windows))]
659 let script = "printf 'monitoring hook fixture is Windows-only'; sleep 60";
660 let launch = provider_launch(script);
661 let spec = provider_spec(
662 MONITORING_HOOK_FIXTURE_ID,
663 "Production monitoring Hook fixture",
664 launch,
665 AgentTransportCapabilities {
666 pty: true,
667 pty_adapter: None,
668 pipe: None,
669 acp: None,
670 },
671 );
672 spec
673}
674
675fn validate_fixture_root(root: &Path) -> Result<(), ControlledExitFixtureError> {
676 if !valid_fixture_path_text(root) || !root.is_absolute() {
677 return Err(ControlledExitFixtureError::InvalidRoot);
678 }
679 for ancestor in root.ancestors() {
680 let metadata = std::fs::symlink_metadata(ancestor)
681 .map_err(|_| ControlledExitFixtureError::InvalidRoot)?;
682 if !metadata.is_dir()
683 || metadata.file_type().is_symlink()
684 || metadata_is_reparse(&metadata)
685 {
686 return Err(ControlledExitFixtureError::InvalidRoot);
687 }
688 }
689 Ok(())
690}
691
692fn validate_fixture_target(
693 root: &Path,
694 target: &Path,
695 name: &'static str,
696) -> Result<String, ControlledExitFixtureError> {
697 if !valid_fixture_path_text(target) || !target.is_absolute() {
698 return Err(ControlledExitFixtureError::InvalidTarget(name));
699 }
700 let relative = target
701 .strip_prefix(root)
702 .map_err(|_| ControlledExitFixtureError::InvalidTarget(name))?;
703 let components = relative.components().collect::<Vec<_>>();
704 if components.is_empty()
705 || components
706 .iter()
707 .any(|component| !matches!(component, Component::Normal(_)))
708 {
709 return Err(ControlledExitFixtureError::InvalidTarget(name));
710 }
711
712 let mut parent = PathBuf::from(root);
713 for component in &components[..components.len() - 1] {
714 parent.push(component.as_os_str());
715 let metadata = std::fs::symlink_metadata(&parent)
716 .map_err(|_| ControlledExitFixtureError::InvalidTarget(name))?;
717 if !metadata.is_dir()
718 || metadata.file_type().is_symlink()
719 || metadata_is_reparse(&metadata)
720 {
721 return Err(ControlledExitFixtureError::InvalidTarget(name));
722 }
723 }
724
725 match std::fs::symlink_metadata(target) {
726 Ok(_) => return Err(ControlledExitFixtureError::TargetExists(name)),
727 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
728 Err(_) => return Err(ControlledExitFixtureError::InvalidTarget(name)),
729 }
730 Ok(target
731 .to_str()
732 .expect("validated fixture target must be Unicode")
733 .to_owned())
734}
735
736fn valid_fixture_path_text(path: &Path) -> bool {
737 matches!(
738 path.to_str(),
739 Some(value) if !value.is_empty()
740 && value.len() <= FIXTURE_PATH_MAX_BYTES
741 && !value.contains('\0')
742 )
743}
744
745#[cfg(windows)]
746fn metadata_is_reparse(metadata: &std::fs::Metadata) -> bool {
747 use std::os::windows::fs::MetadataExt;
748
749 const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400;
750 metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
751}
752
753#[cfg(not(windows))]
754fn metadata_is_reparse(_: &std::fs::Metadata) -> bool {
755 false
756}
757
758fn provider_launch(script: &str) -> LaunchSpec {
759 #[cfg(windows)]
760 return LaunchSpec {
761 program: "powershell.exe".to_owned(),
762 fixed_args: vec![
763 "-NoLogo".to_owned(),
764 "-NoProfile".to_owned(),
765 "-NonInteractive".to_owned(),
766 "-ExecutionPolicy".to_owned(),
767 "Bypass".to_owned(),
768 "-Command".to_owned(),
769 script.to_owned(),
770 ],
771 };
772 #[cfg(not(windows))]
773 return LaunchSpec {
774 program: "sh".to_owned(),
775 fixed_args: vec!["-c".to_owned(), script.to_owned()],
776 };
777}
778
779fn provider_spec(
780 id: &str,
781 display_name: &str,
782 launch: LaunchSpec,
783 transports: AgentTransportCapabilities,
784) -> AgentSpec {
785 let process_name = launch.program.clone();
786 AgentSpec {
787 id: AgentId::new(id).expect("fixture agent ID"),
788 revision: "fixture-r1".to_owned(),
789 display_name: display_name.to_owned(),
790 detection: DetectionSpec {
791 command: launch.program.clone(),
792 aliases: Vec::new(),
793 required_commands: Vec::new(),
794 unsupported_platforms: Vec::new(),
795 },
796 launch,
797 expected_processes: vec![ProcessMatcher::Exact { name: process_name }],
798 prompt: PromptSpec {
799 initial: InitialPromptMode::None,
800 native_draft: None,
801 },
802 readiness: AgentReadinessSpec::default(),
803 capabilities: AgentCapabilities {
804 agent_commands: None,
805 transports,
806 adapters: AgentAdapterCapabilities::default(),
807 },
808 verification: SpecVerification::Gate4AgentVerified,
809 }
810}
811
812fn adapter(family: AdapterFamily, id: &str) -> AdapterBinding {
813 builtin_adapter_registry()
814 .binding(family, id)
815 .unwrap_or_else(|| panic!("missing controlled {family:?} adapter {id}"))
816 .clone()
817}
818
819fn fixture_spec(script: &str) -> AgentSpec {
820 #[cfg(windows)]
821 let (program, fixed_args) = (
822 "powershell.exe",
823 vec![
824 "-NoLogo".to_owned(),
825 "-NoProfile".to_owned(),
826 "-NonInteractive".to_owned(),
827 "-ExecutionPolicy".to_owned(),
828 "Bypass".to_owned(),
829 "-Command".to_owned(),
830 script.to_owned(),
831 ],
832 );
833
834 #[cfg(not(windows))]
835 let (program, fixed_args) = ("sh", vec!["-c".to_owned(), script.to_owned()]);
836
837 AgentSpec {
838 id: AgentId::new(CONTROL_FIXTURE_ID).expect("fixture agent ID"),
839 revision: "fixture-r1".to_owned(),
840 display_name: "Control-plane PTY fixture".to_owned(),
841 detection: DetectionSpec {
842 command: program.to_owned(),
843 aliases: Vec::new(),
844 required_commands: Vec::new(),
845 unsupported_platforms: Vec::new(),
846 },
847 launch: LaunchSpec {
848 program: program.to_owned(),
849 fixed_args,
850 },
851 expected_processes: vec![ProcessMatcher::Exact {
852 name: CONTROL_FIXTURE_ID.to_owned(),
853 }],
854 prompt: PromptSpec {
855 initial: InitialPromptMode::None,
856 native_draft: None,
857 },
858 readiness: AgentReadinessSpec {
859 draft_signal: DraftReadySignal::CursorAfterBracketedPaste,
860 ..AgentReadinessSpec::default()
861 },
862 capabilities: AgentCapabilities {
863 agent_commands: Some(AgentCommandMode::SlashLine),
864 ..AgentCapabilities::default()
865 },
866 verification: SpecVerification::Gate4AgentVerified,
867 }
868}
869
870#[cfg(test)]
871mod tests {
872 use super::*;
873 use std::io::Write;
874 use std::process::{Command, Stdio};
875 use std::sync::atomic::{AtomicU64, Ordering};
876 use std::time::{Duration, Instant};
877
878 static FIXTURE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
879
880 struct ChildGuard(std::process::Child);
881
882 impl Drop for ChildGuard {
883 fn drop(&mut self) {
884 let _ = self.0.kill();
885 let _ = self.0.wait();
886 }
887 }
888
889 #[cfg(not(windows))]
890 #[test]
891 fn unix_interactive_fixture_argv_is_nul_free_and_retains_terminal_escapes() {
892 let spec = interactive_agent_spec();
893 assert!(spec
894 .launch
895 .fixed_args
896 .iter()
897 .all(|argument| !argument.as_bytes().contains(&0)));
898 let script = spec.launch.fixed_args.last().unwrap();
899 assert!(script.contains(r"\033[?2004h"));
900 assert!(script.contains(r"\033[?25h"));
901 }
902
903 #[test]
904 fn controlled_clean_exit_fixture_marks_waits_and_exits_zero_after_release() {
905 suppress_windows_fault_dialogs_for_test();
906 let root = std::env::temp_dir().join(format!(
907 "gate4agent-clean-exit-fixture-{}-{}",
908 std::process::id(),
909 FIXTURE_SEQUENCE.fetch_add(1, Ordering::Relaxed),
910 ));
911 std::fs::create_dir(&root).unwrap();
912 let started_marker = root.join("started.marker");
913 let release_signal = root.join("release.signal");
914 let outside = root.parent().unwrap().join("outside.signal");
915 assert!(matches!(
916 controlled_clean_exit_agent_spec(&root, &started_marker, &outside),
917 Err(ControlledExitFixtureError::InvalidTarget("release signal"))
918 ));
919
920 let spec = controlled_clean_exit_agent_spec(&root, &started_marker, &release_signal)
921 .unwrap();
922 let child = Command::new(&spec.launch.program)
923 .args(&spec.launch.fixed_args)
924 .stdin(Stdio::null())
925 .stdout(Stdio::null())
926 .stderr(Stdio::null())
927 .spawn()
928 .unwrap();
929 let mut child = ChildGuard(child);
930 let marker_deadline = Instant::now() + Duration::from_secs(5);
931 while std::fs::read(&started_marker).ok().as_deref() != Some(b"started\n")
932 && Instant::now() < marker_deadline
933 {
934 std::thread::sleep(Duration::from_millis(10));
935 }
936 assert_eq!(std::fs::read(&started_marker).unwrap(), b"started\n");
937 assert!(child.0.try_wait().unwrap().is_none());
938
939 let mut release = std::fs::OpenOptions::new()
940 .write(true)
941 .create_new(true)
942 .open(&release_signal)
943 .unwrap();
944 release.write_all(b"release\n").unwrap();
945 release.sync_all().unwrap();
946 drop(release);
947 let exit_deadline = Instant::now() + Duration::from_secs(5);
948 let status = loop {
949 if let Some(status) = child.0.try_wait().unwrap() {
950 break status;
951 }
952 if Instant::now() >= exit_deadline {
953 panic!("controlled clean-exit fixture did not exit after release");
954 }
955 std::thread::sleep(Duration::from_millis(10));
956 };
957 assert_eq!(status.code(), Some(0));
958
959 std::fs::remove_file(release_signal).unwrap();
960 std::fs::remove_file(started_marker).unwrap();
961 std::fs::remove_dir(root).unwrap();
962 }
963
964 #[cfg(windows)]
965 #[test]
966 fn monitoring_hook_fixture_posts_exact_ordered_private_provider_events() {
967 let spec = monitoring_hook_agent_spec();
968 assert!(spec.capabilities.transports.pty);
969 assert!(spec.capabilities.adapters.hook.is_none());
972 let script = spec.launch.fixed_args.last().unwrap();
973 let events = [
974 "'SessionStart' 'monitoring-hook-1'",
975 "'UserPromptSubmit' 'monitoring-hook-2'",
976 "'PreToolUse' 'monitoring-hook-3'",
977 "'PostToolUse' 'monitoring-hook-4'",
978 "'SubagentStart' 'monitoring-hook-5'",
979 "'SubagentStop' 'monitoring-hook-6'",
980 "'PermissionRequest' 'monitoring-hook-7'",
981 "'Stop' 'monitoring-hook-8'",
982 ];
983 assert!(script.contains("agent_type = 'research-agent'"));
984 let positions = events.map(|event| {
985 script.find(event).unwrap_or_else(|| panic!("missing fixture event {event}"))
986 });
987 assert!(positions.windows(2).all(|pair| pair[0] < pair[1]));
988 for canary in [
989 MONITORING_PROMPT_CANARY,
990 MONITORING_TOOL_INPUT_CANARY,
991 MONITORING_TOOL_OUTPUT_CANARY,
992 MONITORING_PROVIDER_SESSION_CANARY,
993 MONITORING_SUBAGENT_ID_CANARY,
994 MONITORING_SUBAGENT_DESCRIPTION_CANARY,
995 MONITORING_PERMISSION_INPUT_CANARY,
996 ] {
997 assert!(script.contains(canary));
998 }
999 }
1000}