flowproof 0.9.0__tar.gz → 0.9.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {flowproof-0.9.0 → flowproof-0.9.1}/Cargo.lock +7 -7
  2. {flowproof-0.9.0 → flowproof-0.9.1}/Cargo.toml +1 -1
  3. {flowproof-0.9.0 → flowproof-0.9.1}/PKG-INFO +2 -2
  4. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/agent_runner.rs +117 -0
  5. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/sap_com.rs +52 -7
  6. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/recorder.rs +14 -1
  7. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/agent_flow.rs +58 -1
  8. flowproof-0.9.1/crates/flowproof-cli/tests/falsifiability_audit_since.rs +135 -0
  9. flowproof-0.9.1/crates/flowproof-cli/tests/falsifiability_control_verdict.rs +189 -0
  10. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_e2e.rs +18 -11
  11. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/support/sap_simulator.py +33 -0
  12. {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/__init__.py +1 -1
  13. {flowproof-0.9.0 → flowproof-0.9.1}/pyproject.toml +2 -2
  14. {flowproof-0.9.0 → flowproof-0.9.1}/README.md +0 -0
  15. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/Cargo.toml +0 -0
  16. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
  17. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/egress.rs +0 -0
  18. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
  19. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/lib.rs +0 -0
  20. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  21. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  22. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  23. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/vision.rs +0 -0
  24. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-adapters/src/web.rs +0 -0
  25. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/Cargo.toml +0 -0
  26. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  27. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/author.rs +0 -0
  28. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/clarify.rs +0 -0
  29. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/heal.rs +0 -0
  30. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/lib.rs +0 -0
  31. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/llm.rs +0 -0
  32. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/rules.rs +0 -0
  33. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-agent/src/spec.rs +0 -0
  34. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/Cargo.toml +0 -0
  35. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/capture.rs +0 -0
  36. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/lib.rs +0 -0
  37. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/src/main.rs +0 -0
  38. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -0
  39. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  40. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
  41. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  42. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  43. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
  44. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/doctor_e2e.rs +0 -0
  45. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  46. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/examples_resolve.rs +0 -0
  47. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  48. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
  49. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  50. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  51. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  52. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  53. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
  54. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  55. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  56. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  57. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  58. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  59. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  60. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  61. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  62. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  63. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/Cargo.toml +0 -0
  64. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/app.rs +0 -0
  65. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/backend.rs +0 -0
  66. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/gdi.rs +0 -0
  67. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/lib.rs +0 -0
  68. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/mock.rs +0 -0
  69. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/oob.rs +0 -0
  70. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/recording.rs +0 -0
  71. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/redact.rs +0 -0
  72. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/visual.rs +0 -0
  73. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-driver/src/window.rs +0 -0
  74. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-python/Cargo.toml +0 -0
  75. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-python/src/lib.rs +0 -0
  76. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/Cargo.toml +0 -0
  77. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/lib.rs +0 -0
  78. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/report.rs +0 -0
  79. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/src/runrecord.rs +0 -0
  80. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  81. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/Cargo.toml +0 -0
  82. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  83. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/cassette.rs +0 -0
  84. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/cassette_diff.rs +0 -0
  85. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/egress.rs +0 -0
  86. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/format.rs +0 -0
  87. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/lib.rs +0 -0
  88. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/secret.rs +0 -0
  89. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/secret_scan.rs +0 -0
  90. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/substitution.rs +0 -0
  91. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  92. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  93. {flowproof-0.9.0 → flowproof-0.9.1}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  94. {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/cli.py +0 -0
  95. {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/flow.py +0 -0
  96. {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/mcp_server.py +0 -0
  97. {flowproof-0.9.0 → flowproof-0.9.1}/flowproof/py.typed +0 -0
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "flowproof-adapters"
763
- version = "0.9.0"
763
+ version = "0.9.1"
764
764
  dependencies = [
765
765
  "anyhow",
766
766
  "flowproof-driver",
@@ -779,7 +779,7 @@ dependencies = [
779
779
 
780
780
  [[package]]
781
781
  name = "flowproof-agent"
782
- version = "0.9.0"
782
+ version = "0.9.1"
783
783
  dependencies = [
784
784
  "chrono",
785
785
  "flowproof-driver",
@@ -795,7 +795,7 @@ dependencies = [
795
795
 
796
796
  [[package]]
797
797
  name = "flowproof-cli"
798
- version = "0.9.0"
798
+ version = "0.9.1"
799
799
  dependencies = [
800
800
  "ab_glyph",
801
801
  "chrono",
@@ -816,7 +816,7 @@ dependencies = [
816
816
 
817
817
  [[package]]
818
818
  name = "flowproof-driver"
819
- version = "0.9.0"
819
+ version = "0.9.1"
820
820
  dependencies = [
821
821
  "image",
822
822
  "postgres",
@@ -830,7 +830,7 @@ dependencies = [
830
830
 
831
831
  [[package]]
832
832
  name = "flowproof-python"
833
- version = "0.9.0"
833
+ version = "0.9.1"
834
834
  dependencies = [
835
835
  "flowproof-agent",
836
836
  "flowproof-cli",
@@ -842,7 +842,7 @@ dependencies = [
842
842
 
843
843
  [[package]]
844
844
  name = "flowproof-replay"
845
- version = "0.9.0"
845
+ version = "0.9.1"
846
846
  dependencies = [
847
847
  "chrono",
848
848
  "flowproof-agent",
@@ -856,7 +856,7 @@ dependencies = [
856
856
 
857
857
  [[package]]
858
858
  name = "flowproof-trace"
859
- version = "0.9.0"
859
+ version = "0.9.1"
860
860
  dependencies = [
861
861
  "jsonschema",
862
862
  "regex",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.9.0"
6
+ version = "0.9.1"
7
7
  edition = "2021"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/automators-com/flowproof"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -8,7 +8,7 @@ Classifier: Programming Language :: Rust
8
8
  Classifier: Topic :: Software Development :: Testing
9
9
  Requires-Dist: mcp>=1.2 ; python_full_version >= '3.10' and extra == 'mcp'
10
10
  Provides-Extra: mcp
11
- Summary: Deterministic tests for AI agents: record a run once, replay it with zero LLM calls. Assert which tools an agent called, with which arguments, in which order.
11
+ Summary: Deterministic evidence and control-regression gating for AI agents and the SAP, web and Citrix systems they drive. Record once, replay with zero LLM calls.
12
12
  Author-email: Automators <hello@automators.com>
13
13
  License-Expression: Apache-2.0
14
14
  Requires-Python: >=3.9
@@ -36,6 +36,31 @@ use crate::egress::{AllowSet, EgressLog};
36
36
  /// upstream.
37
37
  const BASE_URL_VARS: [&str; 3] = ["OPENAI_BASE_URL", "OPENAI_API_BASE", "OPENAI_BASE"];
38
38
 
39
+ /// Every variable that can carry a REAL model credential, and which the agent
40
+ /// must therefore never see.
41
+ ///
42
+ /// The agent is third-party code. flowproof holds the upstream key and attaches
43
+ /// it only on the outbound hop, so the agent is handed a placeholder — that was
44
+ /// always the design for `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`, which are
45
+ /// overwritten below.
46
+ ///
47
+ /// What was missing is that overwriting two names is not the same as masking
48
+ /// the credential. `Command` inherits the parent environment, and the *first*
49
+ /// name `flowproof record` looks in is `FLOWPROOF_AGENT_KEY` — so the
50
+ /// recommended way to supply the key was also the one way it reached the agent
51
+ /// verbatim. `FLOWPROOF_AI_API_KEY`, which the LLM-authoring backend reads, did
52
+ /// the same.
53
+ ///
54
+ /// Removed rather than placeheld: a variable flowproof does not hand out has no
55
+ /// business having a value, and an agent reading one is asking a question it
56
+ /// should not get an answer to.
57
+ pub const CREDENTIAL_VARS: [&str; 4] = [
58
+ "FLOWPROOF_AGENT_KEY",
59
+ "FLOWPROOF_AI_API_KEY",
60
+ "ANTHROPIC_API_KEY",
61
+ "OPENAI_API_KEY",
62
+ ];
63
+
39
64
  /// Split a command line into argv, honouring double quotes so a path
40
65
  /// with spaces survives.
41
66
  ///
@@ -366,6 +391,13 @@ fn configure(
366
391
  for var in BASE_URL_VARS {
367
392
  child.env(var, base);
368
393
  }
394
+ // Drop every credential name BEFORE handing out placeholders. `Command`
395
+ // inherits this process's environment, so a name flowproof does not
396
+ // explicitly set is passed through — and the record path runs with a real
397
+ // key in exactly these variables.
398
+ for var in CREDENTIAL_VARS {
399
+ child.env_remove(var);
400
+ }
369
401
  child.env("OPENAI_API_KEY", "flowproof-replay-no-key-needed");
370
402
  // The Anthropic SDK reads its own base URL and appends `/v1/messages`
371
403
  // itself, so it wants the origin WITHOUT the `/v1` the OpenAI vars keep;
@@ -707,6 +739,91 @@ mod tests {
707
739
  assert_eq!(out.len(), 200_000, "every byte the child wrote is captured");
708
740
  }
709
741
 
742
+ /// What `configure` decided for one variable: `Some` to set it, `None`
743
+ /// if it is explicitly removed, and `None` from the outer option if it
744
+ /// was never mentioned — which, since `Command` inherits, means the
745
+ /// child gets whatever this process has.
746
+ fn decided(cmd: &Command, key: &str) -> Option<Option<String>> {
747
+ cmd.get_envs()
748
+ .find(|(k, _)| *k == std::ffi::OsStr::new(key))
749
+ .map(|(_, v)| v.map(|v| v.to_string_lossy().into_owned()))
750
+ }
751
+
752
+ /// The agent is third-party code and flowproof holds the real key, so the
753
+ /// key must not be in the agent's environment. Two names were: the record
754
+ /// path reads `FLOWPROOF_AGENT_KEY` FIRST, so the recommended way to
755
+ /// supply the credential was also the one way it reached the agent intact.
756
+ ///
757
+ /// Asserted on the `Command` rather than by spawning. Proving it by
758
+ /// spawning means putting a real value in this process's environment, and
759
+ /// `set_var` is process-global — with tests running in parallel that is a
760
+ /// race, which is exactly what #236 was. `env_remove` guarantees the child
761
+ /// sees nothing regardless of the parent, so reading the decision is both
762
+ /// deterministic and a stronger claim than one spawn happening to be clean.
763
+ #[test]
764
+ fn a_real_credential_is_never_handed_to_the_agent() {
765
+ let cmd =
766
+ configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
767
+ for var in ["FLOWPROOF_AGENT_KEY", "FLOWPROOF_AI_API_KEY"] {
768
+ assert_eq!(
769
+ decided(&cmd, var),
770
+ Some(None),
771
+ "{var} must be REMOVED from the agent's environment; \
772
+ inheriting it hands third-party code the real upstream key"
773
+ );
774
+ }
775
+ }
776
+
777
+ /// The other direction, and it is not implied by the one above: a client
778
+ /// that refuses to start without a key must still get one, or masking the
779
+ /// credential would break every agent it protects. Placeholders, not
780
+ /// removal, for exactly these two.
781
+ #[test]
782
+ fn the_agent_still_gets_a_key_shaped_placeholder() {
783
+ let cmd =
784
+ configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
785
+ for var in ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] {
786
+ assert_eq!(
787
+ decided(&cmd, var),
788
+ Some(Some("flowproof-replay-no-key-needed".to_string())),
789
+ "{var} must be a placeholder: present, and worthless"
790
+ );
791
+ }
792
+ }
793
+
794
+ /// Every name that can hold a real credential has to be covered, not just
795
+ /// the two that were noticed. This fails when someone adds a fifth.
796
+ #[test]
797
+ fn every_credential_variable_is_accounted_for() {
798
+ let cmd =
799
+ configure("echo hi", "http://127.0.0.1:9/v1", &BTreeMap::new()).expect("configure");
800
+ for var in CREDENTIAL_VARS {
801
+ let d = decided(&cmd, var);
802
+ assert!(
803
+ d == Some(None) || d == Some(Some("flowproof-replay-no-key-needed".to_string())),
804
+ "{var} is in CREDENTIAL_VARS but configure() neither removes it \
805
+ nor replaces it with the placeholder; it would be inherited"
806
+ );
807
+ }
808
+ }
809
+
810
+ /// A spec that deliberately passes a credential through — `agent.env` with
811
+ /// `ANTHROPIC_API_KEY: ${SECRET}` — is a documented escape hatch, and spec
812
+ /// env is applied last precisely so it wins. Masking must not quietly take
813
+ /// that away; an adopter relying on it would see their agent lose its key
814
+ /// with no error.
815
+ #[test]
816
+ fn a_spec_can_still_pass_a_key_through_on_purpose() {
817
+ let mut env = BTreeMap::new();
818
+ env.insert("ANTHROPIC_API_KEY".to_string(), "sk-deliberate".to_string());
819
+ let cmd = configure("echo hi", "http://127.0.0.1:9/v1", &env).expect("configure");
820
+ assert_eq!(
821
+ decided(&cmd, "ANTHROPIC_API_KEY"),
822
+ Some(Some("sk-deliberate".to_string())),
823
+ "the spec's own env is applied last and must still override"
824
+ );
825
+ }
826
+
710
827
  /// The gap a real adopter hit: their client reads AI_GATEWAY_URL, and
711
828
  /// the proxy's port is not known when the spec is written, so a static
712
829
  /// `agent.env` value could not reach it.
@@ -605,6 +605,39 @@ pub mod com {
605
605
  .map(Disp)
606
606
  }
607
607
 
608
+ /// Log in on SAP's own logon screen, using the standard fields every
609
+ /// SAP system ships (client/user/password - the same ones a VBScript
610
+ /// recording of a manual login would drive). A no-op unless both
611
+ /// SAP_USER and SAP_PASSWORD are set: attach-only and an explicit
612
+ /// OpenConnection-without-login stay exactly as they were before this
613
+ /// existed. Values are read fresh from the environment and used only
614
+ /// as this one property-put each - never logged, never traced, never
615
+ /// stored anywhere by this function.
616
+ fn try_auto_login(session: &Disp) {
617
+ let (Ok(user), Ok(password)) =
618
+ (std::env::var("SAP_USER"), std::env::var("SAP_PASSWORD"))
619
+ else {
620
+ return;
621
+ };
622
+ eprintln!("sap-com: not logged in - attempting auto-login");
623
+ if let Some(client) = std::env::var("SAP_CLIENT").ok().filter(|c| !c.is_empty()) {
624
+ if let Some(field) = Self::find_in(session, "wnd[0]/usr/txtRSYST-MANDT") {
625
+ let _ = field.put("Text", VARIANT::from(BSTR::from(client.as_str())));
626
+ }
627
+ }
628
+ if let Some(field) = Self::find_in(session, "wnd[0]/usr/txtRSYST-BNAME") {
629
+ let _ = field.put("Text", VARIANT::from(BSTR::from(user.as_str())));
630
+ }
631
+ if let Some(field) = Self::find_in(session, "wnd[0]/usr/pwdRSYST-BCODE") {
632
+ let _ = field.put("Text", VARIANT::from(BSTR::from(password.as_str())));
633
+ }
634
+ // Submit - Enter on the window, exactly how navigate() confirms
635
+ // any other screen.
636
+ if let Some(window) = Self::find_in(session, "wnd[0]") {
637
+ let _ = window.call("SendVKey", vec![VARIANT::from(0i32)]);
638
+ }
639
+ }
640
+
608
641
  fn walk_into(element: &Disp, depth: u32, out: &mut Vec<SapElement>) {
609
642
  if depth > 14 || out.len() >= 400 {
610
643
  return;
@@ -698,8 +731,9 @@ pub mod com {
698
731
  }
699
732
  let deadline = Instant::now() + timeout;
700
733
  let mut opened = false;
734
+ let mut login_attempted = false;
701
735
  loop {
702
- let attempt = (|| -> Result<Option<Disp>, DriverError> {
736
+ let attempt = (|| -> Result<(Option<Disp>, bool), DriverError> {
703
737
  let sapgui = Disp(attach_to_sapgui()?);
704
738
  let engine = sapgui.call_disp("GetScriptingEngine", vec![])?;
705
739
  let connections = engine.get_disp("Children")?;
@@ -710,12 +744,12 @@ pub mod com {
710
744
  vec![VARIANT::from(BSTR::from(connection)), VARIANT::from(true)],
711
745
  )?;
712
746
  }
713
- return Ok(None); // keep waiting for a session
747
+ return Ok((None, false)); // keep waiting for a session
714
748
  }
715
749
  let conn = connections.call_disp("ElementAt", vec![VARIANT::from(0)])?;
716
750
  let sessions = conn.get_disp("Children")?;
717
751
  if sessions.get_i32("Count").unwrap_or(0) == 0 {
718
- return Ok(None);
752
+ return Ok((None, false));
719
753
  }
720
754
  let session = sessions.call_disp("ElementAt", vec![VARIANT::from(0)])?;
721
755
  // A session object exists as soon as its window opens -
@@ -732,17 +766,28 @@ pub mod com {
732
766
  .map(|info| !info.get_string("User").is_empty())
733
767
  .unwrap_or(false);
734
768
  if !logged_in {
735
- return Ok(None); // keep waiting - not logged in yet
769
+ // #226 follow-up: an unattended nightly run has
770
+ // nobody to log back in when a session expires.
771
+ // SAP_USER/SAP_PASSWORD let us do that ourselves,
772
+ // once per attach - a no-op when they're unset, so
773
+ // attach-only and interactive use are unchanged.
774
+ if !login_attempted {
775
+ Self::try_auto_login(&session);
776
+ }
777
+ return Ok((None, true)); // keep waiting - not logged in yet
736
778
  }
737
- Ok(Some(session))
779
+ Ok((Some(session), false))
738
780
  })();
739
781
 
740
782
  match attempt {
741
- Ok(Some(session)) => {
783
+ Ok((Some(session), _)) => {
742
784
  self.session = Some(session);
743
785
  return Ok(());
744
786
  }
745
- Ok(None) => opened = !connection.is_empty(),
787
+ Ok((None, at_login_screen)) => {
788
+ opened = !connection.is_empty();
789
+ login_attempted = login_attempted || at_login_screen;
790
+ }
746
791
  Err(e) if Instant::now() >= deadline => {
747
792
  return Err(DriverError::Uia(format!(
748
793
  "{e} — start SAP Logon, log in, and enable scripting \
@@ -37,6 +37,19 @@ const STEP_TIMEOUT_MS: u64 = 5000;
37
37
  /// Poll cadence while an auto-waiting assertion is pending.
38
38
  const ASSERT_POLL_INTERVAL: Duration = Duration::from_millis(250);
39
39
 
40
+ /// The precondition wait baked into every targeted step at record time.
41
+ /// `STEP_TIMEOUT_MS` stays the default for everyone - override only when
42
+ /// recording somewhere with real reason to expect more latency (e.g. a
43
+ /// self-hosted CI runner reaching its app over a slower network hop than a
44
+ /// developer's own machine). The replayed value lives in the trace, not
45
+ /// this constant, so this only matters at record time.
46
+ fn step_timeout_ms() -> u64 {
47
+ std::env::var("FLOWPROOF_STEP_TIMEOUT_MS")
48
+ .ok()
49
+ .and_then(|v| v.parse().ok())
50
+ .unwrap_or(STEP_TIMEOUT_MS)
51
+ }
52
+
40
53
  #[derive(Debug, thiserror::Error)]
41
54
  pub enum RecordError {
42
55
  #[error(transparent)]
@@ -812,7 +825,7 @@ fn step_for(id: usize, intent: &str, app: &str, action: &ResolvedAction) -> Step
812
825
  vec![]
813
826
  } else {
814
827
  vec![Condition::ElementExists {
815
- timeout_ms: STEP_TIMEOUT_MS,
828
+ timeout_ms: step_timeout_ms(),
816
829
  selector_ref: None,
817
830
  }]
818
831
  };
@@ -1462,10 +1462,17 @@ mod tests {
1462
1462
 
1463
1463
  /// Write a trace file the way `record` would, but hand-built so the test
1464
1464
  /// needs no real model - a driver-blind cassette on disk.
1465
+ ///
1466
+ /// The name carries a nonce as well as the pid. Cargo runs tests as threads
1467
+ /// in ONE process, so a pid-only name is shared by every caller: two of them
1468
+ /// wrote the same file while a third read it, and the reader got
1469
+ /// `EOF while parsing`. That flake failed CI on two unrelated pull requests
1470
+ /// before anyone traced it. Same reason `setup` pairs the pid with
1471
+ /// `mcp_nonce()` a few hundred lines up.
1465
1472
  fn write_trace(cassette: Cassette) -> std::path::PathBuf {
1466
1473
  let dir = std::env::temp_dir().join("flowproof-agent-flow");
1467
1474
  std::fs::create_dir_all(&dir).expect("temp dir");
1468
- let path = dir.join(format!("trace-{}.json", std::process::id()));
1475
+ let path = dir.join(format!("trace-{}-{}.json", std::process::id(), mcp_nonce()));
1469
1476
  let trace = AgentTrace {
1470
1477
  app: "agent".into(),
1471
1478
  mocks: BTreeMap::new(),
@@ -1481,6 +1488,56 @@ mod tests {
1481
1488
  path
1482
1489
  }
1483
1490
 
1491
+ /// Concurrent `write_trace` calls must not collide.
1492
+ ///
1493
+ /// This asserts the property directly rather than hoping a race shows up:
1494
+ /// with a pid-only name every thread returns the SAME path, so the
1495
+ /// distinctness assertion fails every time instead of one run in twenty.
1496
+ /// Running the suite repeatedly proved nothing - eight consecutive green
1497
+ /// full-suite runs on a four-core box failed to reproduce the flake that had
1498
+ /// already broken CI twice.
1499
+ #[test]
1500
+ fn concurrent_write_trace_calls_get_their_own_files() {
1501
+ use std::sync::{Arc, Barrier};
1502
+
1503
+ const THREADS: usize = 8;
1504
+ let barrier = Arc::new(Barrier::new(THREADS));
1505
+ let handles: Vec<_> = (0..THREADS)
1506
+ .map(|i| {
1507
+ let barrier = Arc::clone(&barrier);
1508
+ std::thread::spawn(move || {
1509
+ // Start together, to overlap the write as much as possible.
1510
+ barrier.wait();
1511
+ write_trace(neutral_cassette(
1512
+ &format!("prompt {i}"),
1513
+ &format!("reply {i}"),
1514
+ ))
1515
+ })
1516
+ })
1517
+ .collect();
1518
+
1519
+ let paths: Vec<_> = handles
1520
+ .into_iter()
1521
+ .map(|h| h.join().expect("thread"))
1522
+ .collect();
1523
+
1524
+ let unique: std::collections::BTreeSet<_> = paths.iter().collect();
1525
+ assert_eq!(
1526
+ unique.len(),
1527
+ THREADS,
1528
+ "every concurrent write_trace needs its own path; got {} distinct of {THREADS}",
1529
+ unique.len()
1530
+ );
1531
+
1532
+ // And each file must be complete: a shared path leaves a reader parsing
1533
+ // a half-written file, which is the `EOF while parsing` the flake showed.
1534
+ for path in &paths {
1535
+ let text = std::fs::read_to_string(path).expect("trace readable");
1536
+ serde_json::from_str::<AgentTrace>(&text)
1537
+ .unwrap_or_else(|e| panic!("{} is not a complete trace: {e}", path.display()));
1538
+ }
1539
+ }
1540
+
1484
1541
  /// A free localhost port: bind ephemeral, read the number, release it.
1485
1542
  /// A tiny race, acceptable in a test - the proxy re-binds it immediately.
1486
1543
  fn free_port() -> u16 {
@@ -0,0 +1,135 @@
1
+ //! Falsifiability proof for the `audit --since` regression gate (issue #254).
2
+ //!
3
+ //! `audit_record_e2e.rs` proves the gate FIRES: a removed control and a
4
+ //! pass->fail change both exit non-zero. Nothing proves it can DECLINE to
5
+ //! fire. A gate wired to exit non-zero unconditionally would satisfy every
6
+ //! assertion in that file, and CI would go red on every clean run until
7
+ //! somebody stopped believing it.
8
+ //!
9
+ //! That is the shape of a false signal in a gate: not a missed regression, but
10
+ //! a gate that says "regression" so often it stops meaning anything. So the
11
+ //! property under proof here is the discriminating half --
12
+ //! `audit --since` exits ZERO when nothing regressed -- which is what makes
13
+ //! its non-zero exit worth acting on.
14
+ //!
15
+ //! Both layers, as everywhere in this suite:
16
+ //! 1. the diff CONTENT (`audit --since --json`), and
17
+ //! 2. the process EXIT CODE a CI run would branch on.
18
+ //!
19
+ //! A gate that reported an empty diff while still exiting non-zero would pass
20
+ //! a content-only check, and would still break every pipeline that uses it.
21
+
22
+ use std::path::{Path, PathBuf};
23
+
24
+ const FLOWPROOF_BIN: &str = env!("CARGO_BIN_EXE_flowproof");
25
+
26
+ fn fixture(name: &str) -> PathBuf {
27
+ Path::new(env!("CARGO_MANIFEST_DIR"))
28
+ .join("../../tests/falsifiability/fixtures/audit-since")
29
+ .join(name)
30
+ }
31
+
32
+ fn work_dir(name: &str) -> PathBuf {
33
+ let dir = std::env::temp_dir().join(format!("flowproof-fals-since-{name}"));
34
+ std::fs::remove_dir_all(&dir).ok();
35
+ std::fs::create_dir_all(&dir).expect("work dir");
36
+ dir
37
+ }
38
+
39
+ /// Install a committed record fixture as a real run record, in the layout
40
+ /// `flowproof run` would have written. The directory name is taken from the
41
+ /// record's own `run_id`, so the two can never drift apart.
42
+ fn install(dir: &Path, fixture_name: &str) -> String {
43
+ let body = std::fs::read_to_string(fixture(fixture_name)).expect("read fixture");
44
+ let value: serde_json::Value = serde_json::from_str(&body).expect("fixture is JSON");
45
+ let run_id = value["run_id"].as_str().expect("run_id").to_string();
46
+ let run_dir = dir.join(".flowproof").join("runs").join(&run_id);
47
+ std::fs::create_dir_all(&run_dir).expect("run dir");
48
+ std::fs::write(run_dir.join("report.json"), body).expect("install record");
49
+ run_id
50
+ }
51
+
52
+ fn audit_since(dir: &Path, base: &str) -> std::process::Output {
53
+ std::process::Command::new(FLOWPROOF_BIN)
54
+ .args([
55
+ "audit",
56
+ dir.to_str().expect("utf8"),
57
+ "--since",
58
+ base,
59
+ "--json",
60
+ ])
61
+ .output()
62
+ .expect("audit --since")
63
+ }
64
+
65
+ /// Two runs with identical controls are not a regression. The gate must stay
66
+ /// silent, and it must exit zero.
67
+ #[test]
68
+ fn an_unchanged_control_set_is_not_a_regression() {
69
+ let dir = work_dir("unchanged");
70
+ let base = install(&dir, "base.report.json");
71
+ install(&dir, "head-unchanged.report.json");
72
+
73
+ let out = audit_since(&dir, &base);
74
+
75
+ // Layer 2: the exit code CI branches on.
76
+ assert!(
77
+ out.status.success(),
78
+ "an unchanged control set must exit zero, or the gate cries wolf on \
79
+ every clean run; stdout={} stderr={}",
80
+ String::from_utf8_lossy(&out.stdout),
81
+ String::from_utf8_lossy(&out.stderr)
82
+ );
83
+
84
+ // Layer 1: and it must say nothing changed, rather than exiting zero while
85
+ // reporting a diff -- which would be the same defect wearing the other face.
86
+ let diff: serde_json::Value =
87
+ serde_json::from_slice(&out.stdout).expect("audit --since --json is valid JSON");
88
+ for section in ["added", "removed", "changed"] {
89
+ assert_eq!(
90
+ diff[section].as_array().map(Vec::len).unwrap_or(0),
91
+ 0,
92
+ "{section} must be empty for an unchanged pair: {diff}"
93
+ );
94
+ }
95
+
96
+ std::fs::remove_dir_all(&dir).ok();
97
+ }
98
+
99
+ /// Gaining a control is not a regression. It is reported, because a reviewer
100
+ /// wants to see it, but it must not fail the build: a suite that could never
101
+ /// grow without going red would teach its owners to stop adding controls.
102
+ #[test]
103
+ fn an_added_control_is_reported_but_does_not_fail_the_gate() {
104
+ let dir = work_dir("added");
105
+ let base = install(&dir, "base.report.json");
106
+ install(&dir, "head-added.report.json");
107
+
108
+ let out = audit_since(&dir, &base);
109
+
110
+ assert!(
111
+ out.status.success(),
112
+ "adding a control must not fail the gate; stdout={} stderr={}",
113
+ String::from_utf8_lossy(&out.stdout),
114
+ String::from_utf8_lossy(&out.stderr)
115
+ );
116
+
117
+ let diff: serde_json::Value =
118
+ serde_json::from_slice(&out.stdout).expect("audit --since --json is valid JSON");
119
+ assert_eq!(
120
+ diff["added"][0]["id"], "falsifiability.since.gamma",
121
+ "the addition is still reported: {diff}"
122
+ );
123
+ assert_eq!(
124
+ diff["removed"].as_array().map(Vec::len).unwrap_or(0),
125
+ 0,
126
+ "nothing was removed: {diff}"
127
+ );
128
+ assert_eq!(
129
+ diff["changed"].as_array().map(Vec::len).unwrap_or(0),
130
+ 0,
131
+ "nothing changed verdict: {diff}"
132
+ );
133
+
134
+ std::fs::remove_dir_all(&dir).ok();
135
+ }
@@ -0,0 +1,189 @@
1
+ //! Falsifiability proof for `control:` verdict mapping (issue #253).
2
+ //!
3
+ //! `audit_record_e2e.rs` proves audit READS a record rather than re-replaying,
4
+ //! and that `--since` exits non-zero on a regression. But every verdict it
5
+ //! asserts is either `capability-error` (a flow skipped by an env gate) or
6
+ //! hand-written into a record fixture. Nothing there runs a control-bearing
7
+ //! flow that genuinely FAILS and checks what verdict the record receives.
8
+ //!
9
+ //! That is the gap this closes, and it is the highest-consequence false green
10
+ //! available: the control map is what a reader trusts when they cannot read
11
+ //! the trace, so a control reporting `pass` over a failed flow would misreport
12
+ //! the one thing the artifact exists to say.
13
+ //!
14
+ //! The red path is built the way `api_pipeline.rs` builds its green one -- a
15
+ //! real loopback server, with the host travelling as `${API_BASE}` so it never
16
+ //! enters the trace. Record against a live endpoint (an honest trace is
17
+ //! minted), then run with the port dead, so the assertion fails for real at
18
+ //! replay rather than being simulated.
19
+ //!
20
+ //! Two layers, deliberately, because the streaming false green of 0.9.0 got
21
+ //! through by being checked at the wrong one:
22
+ //! 1. the VERDICT recorded in `.flowproof/runs/<id>/report.json`, and
23
+ //! re-rendered by `flowproof audit`;
24
+ //! 2. the process EXIT CODE of the real binary.
25
+ //!
26
+ //! A run that failed loudly while recording `verdict: pass` would sail through
27
+ //! a one-layer check.
28
+
29
+ use std::path::{Path, PathBuf};
30
+
31
+ const FLOWPROOF_BIN: &str = env!("CARGO_BIN_EXE_flowproof");
32
+
33
+ /// A port with nothing on it. Chosen by binding and dropping, so the number is
34
+ /// free at this instant rather than merely assumed to be.
35
+ fn dead_base() -> String {
36
+ let probe = std::net::TcpListener::bind("127.0.0.1:0").expect("probe binds");
37
+ let addr = probe.local_addr().expect("probe addr");
38
+ drop(probe);
39
+ format!("http://{addr}")
40
+ }
41
+
42
+ /// Serve `GET /health` -> 200 for up to `requests` requests, then stop.
43
+ ///
44
+ /// Deliberately DETACHED and never joined. Joining would block forever the
45
+ /// moment the flow makes fewer probes than the count guessed here -- a hang is
46
+ /// a far worse failure mode for a falsifiability test than an over-generous
47
+ /// server, because a hung test reports nothing at all.
48
+ fn serve(server: tiny_http::Server, requests: usize) {
49
+ std::thread::spawn(move || {
50
+ for _ in 0..requests {
51
+ let Ok(request) = server.recv() else { break };
52
+ let (code, body) = if request.url() == "/health" {
53
+ (200, r#"{"status":"ok"}"#)
54
+ } else {
55
+ (404, r#"{"error":"not found"}"#)
56
+ };
57
+ request
58
+ .respond(tiny_http::Response::from_string(body).with_status_code(code))
59
+ .ok();
60
+ }
61
+ });
62
+ }
63
+
64
+ /// The guilty flow, kept as a committed fixture so it is reviewable as
65
+ /// evidence rather than buried in a string literal.
66
+ fn fixture() -> PathBuf {
67
+ Path::new(env!("CARGO_MANIFEST_DIR"))
68
+ .join("../../tests/falsifiability/fixtures/control-verdict-fail.flow.yaml")
69
+ }
70
+
71
+ fn work_dir(name: &str) -> PathBuf {
72
+ let dir = std::env::temp_dir().join(format!("flowproof-fals-control-{name}"));
73
+ std::fs::remove_dir_all(&dir).ok();
74
+ std::fs::create_dir_all(&dir).expect("work dir");
75
+ dir
76
+ }
77
+
78
+ /// The single `report.json` written under `<dir>/.flowproof/runs`.
79
+ fn find_record(dir: &Path) -> Option<PathBuf> {
80
+ let runs = dir.join(".flowproof").join("runs");
81
+ for entry in std::fs::read_dir(&runs).ok()?.filter_map(Result::ok) {
82
+ let candidate = entry.path().join("report.json");
83
+ if candidate.is_file() {
84
+ return Some(candidate);
85
+ }
86
+ }
87
+ None
88
+ }
89
+
90
+ /// A control-bearing flow that fails must be recorded as `fail` -- never
91
+ /// `pass`, and never quietly downgraded to `capability-error`, which would
92
+ /// read as "we could not tell" when in fact we could.
93
+ #[test]
94
+ fn a_failing_control_flow_is_recorded_as_fail() {
95
+ let dir = work_dir("verdict");
96
+ let spec = dir.join("control-verdict-fail.flow.yaml");
97
+ std::fs::copy(fixture(), &spec).expect("stage fixture");
98
+
99
+ // --- Record honestly, against a live endpoint. ---
100
+ let server = tiny_http::Server::http("127.0.0.1:0").expect("server binds");
101
+ let live = format!("http://{}", server.server_addr());
102
+ serve(server, 4);
103
+ std::env::set_var("API_BASE", &live);
104
+
105
+ let rec = std::process::Command::new(FLOWPROOF_BIN)
106
+ .arg("record")
107
+ .arg(&spec)
108
+ .current_dir(&dir)
109
+ .env("API_BASE", &live)
110
+ .output()
111
+ .expect("record against the live endpoint");
112
+ assert!(
113
+ rec.status.success(),
114
+ "the recording leg must be honest and green; stdout={} stderr={}",
115
+ String::from_utf8_lossy(&rec.stdout),
116
+ String::from_utf8_lossy(&rec.stderr)
117
+ );
118
+
119
+ // --- Now run it with the endpoint gone. The assertion fails for real. ---
120
+ let dead = dead_base();
121
+ let out = std::process::Command::new(FLOWPROOF_BIN)
122
+ .arg("run")
123
+ .arg(&spec)
124
+ .current_dir(&dir)
125
+ .env("API_BASE", &dead)
126
+ .output()
127
+ .expect("run against the dead endpoint");
128
+
129
+ // Layer 2: the exit code CI would see.
130
+ assert!(
131
+ !out.status.success(),
132
+ "a flow whose assertion cannot pass must exit non-zero; stdout={} stderr={}",
133
+ String::from_utf8_lossy(&out.stdout),
134
+ String::from_utf8_lossy(&out.stderr)
135
+ );
136
+
137
+ // Layer 1: the verdict the record actually carries.
138
+ let record = find_record(&dir).unwrap_or_else(|| {
139
+ panic!(
140
+ "a failing run must still write a run record, or the control \
141
+ vanishes from the audit map instead of reporting fail; stdout={} stderr={}",
142
+ String::from_utf8_lossy(&out.stdout),
143
+ String::from_utf8_lossy(&out.stderr)
144
+ )
145
+ });
146
+ let body = std::fs::read_to_string(&record).expect("read record");
147
+ let value: serde_json::Value = serde_json::from_str(&body).expect("record is JSON");
148
+ let verdict = value["flows"]
149
+ .as_array()
150
+ .expect("flows array")
151
+ .iter()
152
+ .find_map(|f| {
153
+ let c = &f["control"];
154
+ (c["id"] == "falsifiability.control.verdict").then(|| c["verdict"].clone())
155
+ })
156
+ .unwrap_or_else(|| panic!("the record folds the flow's control: {body}"));
157
+
158
+ assert_eq!(
159
+ verdict, "fail",
160
+ "a control whose flow failed must be recorded as fail, not {verdict} -- \
161
+ this is the false green the audit map cannot afford: {body}"
162
+ );
163
+
164
+ // And `audit` must re-render that verdict rather than soften it.
165
+ let audit = std::process::Command::new(FLOWPROOF_BIN)
166
+ .args(["audit", dir.to_str().expect("utf8"), "--json"])
167
+ .output()
168
+ .expect("audit --json");
169
+ let map: serde_json::Value =
170
+ serde_json::from_slice(&audit.stdout).expect("audit --json is valid JSON");
171
+ let rendered = map["controls"]
172
+ .as_array()
173
+ .expect("controls array")
174
+ .iter()
175
+ .find(|c| c["id"] == "falsifiability.control.verdict")
176
+ .unwrap_or_else(|| {
177
+ panic!(
178
+ "audit names the control: {}",
179
+ String::from_utf8_lossy(&audit.stdout)
180
+ )
181
+ })
182
+ .clone();
183
+ assert_eq!(
184
+ rendered["verdict"], "fail",
185
+ "audit renders the recorded verdict unchanged"
186
+ );
187
+
188
+ std::fs::remove_dir_all(&dir).ok();
189
+ }
@@ -1,6 +1,7 @@
1
- //! End-to-end against a REAL SAP GUI session. No CI runner has one, so
2
- //! this is maintainer-run: Windows, SAP GUI installed, logged in, scripting
3
- //! enabled (`sapgui/user_scripting = TRUE`), and FLOWPROOF_E2E_SAP=1.
1
+ //! End-to-end against a REAL SAP GUI session: on the self-hosted `sap`
2
+ //! runner via .github/workflows/sap-e2e.yml (see #32), or maintainer-run
3
+ //! locally with Windows, SAP GUI installed, logged in, scripting enabled
4
+ //! (`sapgui/user_scripting = TRUE`), and FLOWPROOF_E2E_SAP=1.
4
5
  //!
5
6
  //! The flow only navigates and reads — it types a transaction code and
6
7
  //! asserts on the status/title, touching no business data.
@@ -10,13 +11,19 @@
10
11
  use flowproof_adapters::sap_com::SapAppDriver;
11
12
  use flowproof_agent::FlowSpec;
12
13
 
13
- const SPEC: &str = "\
14
- name: Navigate to session status
15
- app: sap
16
- steps:
17
- - Go to /nSESSION_MANAGER
18
- - assert: page shows Session
19
- ";
14
+ /// A logged-in session already exists on every machine this test runs on
15
+ /// today (attach-only, `connection` omitted). SAP_CONNECTION lets an
16
+ /// unattended nightly run open one instead, should the runner ever come up
17
+ /// without one — the value is carried as `${VAR}` and resolved at launch
18
+ /// time, so it never reaches the trace.
19
+ fn spec_yaml() -> String {
20
+ let mut spec = String::from("name: Navigate to session status\napp: sap\n");
21
+ if std::env::var("SAP_CONNECTION").is_ok() {
22
+ spec.push_str("connection: ${SAP_CONNECTION}\n");
23
+ }
24
+ spec.push_str("steps:\n - Go to /nSESSION_MANAGER\n - assert: page shows Session\n");
25
+ spec
26
+ }
20
27
 
21
28
  #[test]
22
29
  fn navigates_a_real_sap_session() {
@@ -31,7 +38,7 @@ fn navigates_a_real_sap_session() {
31
38
  std::fs::create_dir_all(&dir).expect("temp dir");
32
39
  let trace_path = dir.join("session.trace.jsonl");
33
40
 
34
- let spec = FlowSpec::parse(SPEC).expect("spec parses");
41
+ let spec = FlowSpec::parse(&spec_yaml()).expect("spec parses");
35
42
 
36
43
  let mut driver = SapAppDriver::new().expect("COM engine");
37
44
  flowproof_agent::record(&spec, &mut driver, &trace_path).expect("records against live SAP");
@@ -118,12 +118,45 @@ class Collection:
118
118
  Item = ElementAt
119
119
 
120
120
 
121
+ class SessionInfo:
122
+ """`GuiSession.Info`, and specifically its `User`.
123
+
124
+ The engine uses a non-empty `User` to tell a logged-in session from one
125
+ sitting at the SAP login screen: a session object exists as soon as its
126
+ window opens, login screen included, so existence alone proves nothing.
127
+ Real SAP reports transaction `S000` and an empty user until someone
128
+ authenticates.
129
+
130
+ The simulator therefore has to present a session that is actually logged
131
+ in, because that is what the test claims to exercise - "record through the
132
+ production COM engine against a running, logged-in session". A simulator
133
+ that reported an empty user would be asking the engine to relax the check
134
+ rather than asking the test to be honest.
135
+ """
136
+
137
+ _public_methods_ = []
138
+ _public_attrs_ = ["User", "Client", "Transaction", "SystemName"]
139
+
140
+ def __init__(self, user="FLOWPROOF", client="001", transaction="VA01",
141
+ system="SIM"):
142
+ self.User = user
143
+ self.Client = client
144
+ self.Transaction = transaction
145
+ self.SystemName = system
146
+
147
+
121
148
  class Session(Component):
122
149
  _public_methods_ = Component._public_methods_ + ["FindById"]
150
+ _public_attrs_ = Component._public_attrs_ + ["Info"]
123
151
 
124
152
  def __init__(self, screen):
125
153
  Component.__init__(self, screen, "ses", "GuiSession", "ses[0]")
126
154
  self.Id = "/app/con[0]/ses[0]"
155
+ # Wrapped, like every other nested object here (`Children`, `child`,
156
+ # `connection`). An unwrapped Python instance is not dispatchable, so
157
+ # the engine's `get_disp("Info")` would fail and the session would look
158
+ # exactly as "not logged in" as before.
159
+ self.Info = wrap(SessionInfo())
127
160
 
128
161
  def FindById(self, element_id):
129
162
  element = self._screen.by_id.get(str(element_id))
@@ -24,7 +24,7 @@ from flowproof.flow import (
24
24
  run,
25
25
  )
26
26
 
27
- __version__ = "0.9.0"
27
+ __version__ = "0.9.1"
28
28
 
29
29
  __all__ = [
30
30
  "ClarificationNeeded",
@@ -4,8 +4,8 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "flowproof"
7
- version = "0.9.0"
8
- description = "Deterministic tests for AI agents: record a run once, replay it with zero LLM calls. Assert which tools an agent called, with which arguments, in which order."
7
+ version = "0.9.1"
8
+ description = "Deterministic evidence and control-regression gating for AI agents and the SAP, web and Citrix systems they drive. Record once, replay with zero LLM calls."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  requires-python = ">=3.9"
File without changes
File without changes
File without changes
File without changes