Skip to main content

gate4agent_testkit/
lib.rs

1//! Controlled, authentication-free provider fixtures for integration tests.
2
3use 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";
17/// Deliberately equal to the real catalog's `grok` agent ID — see
18/// `grok_acp_agent_spec` for why.
19pub 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
72/// Prevent automated Windows fault paths from opening modal UI.
73///
74/// Call this before the first native or provider FFI operation in a test
75/// process. The process error mode is inherited by fixture children.
76pub 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
97/// Refuse to run Windows PTY integration code outside the bounded supervisor.
98///
99/// The supervisor sets this exact marker only after creating its containment
100/// job. Non-Windows tests do not require the Windows-specific containment.
101pub 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
215/// Combines [`controlled_clean_exit_agent_spec`]'s deterministic
216/// marker/release/exit-0 handshake with an early `SessionStart` Hook-shaped
217/// post carrying `session_id`, originally so the session established a
218/// verified `ProviderSessionIdentity` (`provider_session: Some(_)`) before it
219/// waits for release and exits cleanly.
220///
221/// Lifecycle hooks are retired (owner ruling 2026-09-25): there is no hook
222/// ingress listening anywhere anymore, so the script's post to
223/// `$env:GATE4AGENT_HOOK_URL` no longer reaches anything and this fixture no
224/// longer establishes a verified provider session identity by itself. Kept
225/// for its marker/release/exit-0 handshake shape; callers relying on the
226/// identity side of this fixture need an ACP-sourced replacement.
227pub 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
388/// Shared synthetic-peer launch used by both [`acp_agent_spec`] and
389/// [`grok_acp_agent_spec`] — the ACP protocol handshake is transport-generic,
390/// so the same fixture script proves it for any agent ID / adapter binding.
391fn 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
531/// Grok registered over ACP against the same synthetic peer as
532/// [`acp_agent_spec`], but bound to the real `grok` adapter ID (agent ID
533/// equal to `GROK_ACP_FIXTURE_ID`, i.e. the catalog's own `grok` agent ID).
534///
535/// Proves that a `Register` command for `grok` over `TransportKind::Acp`
536/// clears the kernel's transport-support check and reaches spawn, without
537/// depending on a live, authenticated `grok` CLI on the test box.
538pub 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
575/// A PTY fixture whose script posts a synthetic hook payload to
576/// `$env:GATE4AGENT_HOOK_URL`. Lifecycle hooks are retired (owner ruling
577/// 2026-09-25): nothing sets that env var and nothing listens on it anymore,
578/// so the post is inert. Kept as a plain PTY fixture for callers that only
579/// need its launch shape, not a working hook post.
580pub 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
600/// A PTY fixture whose script posts a full ordered sequence of synthetic hook
601/// payloads to `$env:GATE4AGENT_HOOK_URL`. Lifecycle hooks are retired (owner
602/// ruling 2026-09-25): nothing sets that env var and nothing listens on it
603/// anymore, so the posts are inert and this fixture no longer declares a hook
604/// adapter. Kept as a plain PTY fixture for callers that only need its launch
605/// shape, not a working hook post.
606pub 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        // Lifecycle hooks are retired: this fixture no longer declares a
970        // hook adapter (see `monitoring_hook_agent_spec`'s doc comment).
971        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}