diff --git a/CLAUDE.md b/CLAUDE.md index f1ea01c..2719049 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -347,7 +347,7 @@ was deleted the same day. | `shared/scripts/sap_log_lib.vbs` | **All VBScript skill scripts (optional)** | Structured logger. Include via `ExecuteGlobal FSO.OpenTextFile("%%LOG_LIB_VBS%%",1).ReadAll()`. Functions: `LogStart(skill, paramsArray)`, `LogStep(runId, level, step, msg)`, `LogEnd(runId, status, exitCode, errorMsg)`. Same JSONL/TSV/TEXT formats and redaction as the PS lib. Writes UTF-8 (no BOM) via ADODB.Stream so files concatenate cleanly with PS-emitted lines. | | `shared/scripts/sap_session_lock.vbs` | **All GUI-scripting VBS reference scripts that perform multi-step writes (mandatory per Rule 7)** | Session-lock helpers. Include via `ExecuteGlobal FSO.OpenTextFile("%%SESSION_LOCK_VBS%%",1).ReadAll()`. Functions: `TryLockSession(sess)` → returns Boolean (False if API unavailable on this SAP GUI build); `ReleaseSession(sess, wasLocked)` — idempotent unlock that ALSO sweeps up to 5 chained orphan modal popups via `sendVKey 12` (F12 / Cancel) before unlocking, so the user never gets a frozen popup on session handover. Wrap source-paste / save / activate / popup-driving critical sections to block in-session focus stealing. Pair with the existing AppActivate-loop guards for SendKeys-based pastes (defence in depth: AppActivate blocks external focus stealing, LockSessionUI blocks internal, the pre-unlock sweep covers leftover modals). | | `shared/scripts/sap_delete_popups.vbs` | **The delete VBS of sap-se37 / sap-se11 / sap-se24 / sap-se38 / sap-se21** | Shared post-delete popup walker. Included by deriving its path from the already-substituted `%%ATTACH_LIB_VBS%%` token (same dir, so no extra generator token): `sDpDir = oDpFso.GetParentFolderName("%%ATTACH_LIB_VBS%%") : ExecuteGlobal oDpFso.OpenTextFile(oDpFso.BuildPath(sDpDir, "sap_delete_popups.vbs"),1).ReadAll()`. Exposes `Function WalkDeletePopups(oSession, objdirPkg, objdirLang, sapTr)` → walks the active window (cap 10), dispatching each modal by DDIC control id ONLY (locale-independent): SAPLSETX language (`ctxtRSETX-MASTERLANG`/`btnPUSH1`), KO007 "Create Object Directory Entry" (ECC6 — fill empty package from `objdirPkg` + 1-char `objdirLang`, else accept pre-filled, else Local Object `btn[7]`), TR prompt (`ctxtKO008-TRKORR`; returns `"ABORT_EMPTY_TR"` when `sapTr` is empty so the caller releases its lock + `WScript.Quit 1`), and a confirm cascade (`btnSPOP-OPTION1` / `btnBUTTON_1` / `tbar[0]/btn[0]` / Enter). Each branch is gated by its control id, so the union is a strict superset of every per-skill loop it replaced and cannot misfire on a screen lacking that control. Pure function library (receives an already-attached `oSession`; does NOT bind the Scripting engine / declare `SESSION_PATH` / include the attach lib / call `AttachSapSession`), so — like `sap_session_lock.vbs` — it is not a "driving" VBS and lives in `shared/scripts/`, outside the `skills/*/references/` scan scope of `scripts/check-consistency.mjs` (no baseline required). se19 (classic + new) and cmod keep their own popup handling (divergent `For pass` / sequential structure + lenient-TR semantics). | -| `shared/scripts/sap_attach_lib.vbs` | **All GUI-scripting VBS reference scripts that drive SAP GUI** — mandatory for the Tier 3 (parallel-safe session attach) contract. | Shared session-attach primitive — **multi-connection aware (Phase 3.5)** + **pin-file-free (Phase 4.2)**. Include via `ExecuteGlobal FSO.OpenTextFile("%%ATTACH_LIB_VBS%%",1).ReadAll()`. Exposes `Function AttachSapSession(sHint)` which resolves the target session in this order: (1) `sHint` (typically the `%%SESSION_PATH%%` token from the calling wrapper); (2) `SAPDEV_SESSION_PATH` env var — set by the SKILL.md wrapper to `Get-SapCurrentSessionPath`'s return; (3) sole-connection + sole-session safe default; (4) **refuse loud** with `ERROR: N SAP connections attached; cannot pick one safely. Run /sap-login to pin a connection, or pass --session ...`. **Strategies 1 and 2 also work cross-connection** — they take full `/app/con[N]/ses[M]` paths and never silently retarget. Strategies 3 and 4 keep single-connection callers simple while multi-connection callers get safe refusal instead of silent miss-targeting. The convention: each migrated VBS declares `Const SESSION_PATH = "%%SESSION_PATH%%"`, includes this lib, and calls `Set oSession = AttachSapSession(SESSION_PATH)`. Calling skill wrappers (PowerShell) substitute `%%SESSION_PATH%%` with the parsed `--session` argument (or empty), `%%ATTACH_LIB_VBS%%` with the absolute path to this file, AND set `$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp '{WORK_TEMP}'` (from `sap_connection_lib.ps1`) so the AI session's pin propagates. Unsubstituted-token sentinel via a `Chr(37)`-built runtime string so global wrapper substitution cannot corrupt the comparison. Pairs with the broker: the broker decides which session belongs to this AI session; the helper lets every VBS attach to that decision safely. | +| `shared/scripts/sap_attach_lib.vbs` | **All GUI-scripting VBS reference scripts that drive SAP GUI** — mandatory for the Tier 3 (parallel-safe session attach) contract. | Shared session-attach primitive — **multi-connection aware (Phase 3.5)** + **pin-file-free (Phase 4.2)**. Include via `ExecuteGlobal FSO.OpenTextFile("%%ATTACH_LIB_VBS%%",1).ReadAll()`. Exposes `Function AttachSapSession(sHint)` which resolves the target session in this order: (1) `sHint` (typically the `%%SESSION_PATH%%` token from the calling wrapper); (2) `SAPDEV_SESSION_PATH` env var — set by the SKILL.md wrapper to `Get-SapCurrentSessionPath`'s return; (3) sole-connection + sole-session safe default; (4) **refuse loud** with `ERROR: N SAP connections attached; cannot pick one safely. Run /sap-login to pin a connection, or pass --session ...`. **Strategies 1 and 2 also work cross-connection** — they take full `/app/con[N]/ses[M]` paths and never silently retarget. Strategies 3 and 4 keep single-connection callers simple while multi-connection callers get safe refusal instead of silent miss-targeting. The convention: each migrated VBS declares `Const SESSION_PATH = "%%SESSION_PATH%%"`, includes this lib, and calls `Set oSession = AttachSapSession(SESSION_PATH)`. Calling skill wrappers (PowerShell) substitute `%%SESSION_PATH%%` with the parsed `--session` argument (or empty), `%%ATTACH_LIB_VBS%%` with the absolute path to this file, AND set `$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp '{WORK_TEMP}'` (from `sap_connection_lib.ps1`) so the AI session's pin propagates. Unsubstituted-token sentinel via a `Chr(37)`-built runtime string so global wrapper substitution cannot corrupt the comparison. Pairs with the broker: the broker decides which session belongs to this AI session; the helper lets every VBS attach to that decision safely. **Target stamp + assertion (2026-08-06)**: every success path now echoes `GUI_TARGET: system= client= user= path=<...> via=` (read from `GuiSession.Info` — IDs, not localised text), and when the wrapper exports `SAPDEV_EXPECT_SYSTEM` / `SAPDEV_EXPECT_CLIENT` a mismatch is a **hard refusal** (exit 2) instead of a silent retarget. Set them via `Set-SapGuiTargetExpectation` (`sap_connection_lib.ps1`), which resolves the *same* profile `Connect-SapRfc` picks — so the GUI leg and the RFC leg of a skill can no longer land on different SAP systems. **Both must be exported in the same process that launches `cscript`** (env vars die with the generator block). Unset = legacy behaviour, still stamped. | | `shared/scripts/sap_session_broker.ps1` | **All GUI-scripting skills that may run in parallel** (today: `/sap-gui-skill-scaffold` parallel path + the 4 Phase-3.1 migrated read-only skills; will become broadly mandatory after Tier 3 migration). Full contract: `shared/rules/sap_session_broker.md`. | SAP GUI Session Broker — **multi-connection aware (v2 schema, Phase 3.5)**. PowerShell, ~600 LOC. Single-binary CLI with five actions — `acquire` / `release` / `discover` / `gc` / `list` — driven by `-Action -WorkTemp ` + per-action args. State lives in `{WORK_TEMP}\session_registry.json` (UTF-8 no BOM, nested `connections[]` shape); cross-process concurrency serialized by a named Windows mutex (`SapDevSessionBroker_v2`) acquired through `System.Threading.Mutex` with a 10s timeout for crash recovery. **Connection isolation**: a claim resolved against connection N never returns a session of connection M. Reactive cleanup + identity-reconciliation sweep runs inside every acquire/release/discover/gc across ALL connections: it mirrors live SAP identity onto each block (live is source of truth) then drops entries on these failure modes — session closed, owner PID dead, TTL expired, relogin, entire connection closed. A reused `/app/con[N]` slot now hosting a DIFFERENT system is detected by the `(system,client,user)` tuple — NOT `SystemSessionId`, which on the tested kernels is per-workstation, not per-logon, and stays identical across an A→B swap on one slot (the 2026-06-07 stale-identity bug); on a tuple change the block is reset to the live identity and its stale `connection_id` cleared (re-bound on next finalize). Idempotent on re-acquire by `task_id`. Connection-targeting acquire args (Phase 4.1+): broker auto-resolves `-AiSessionId` via parent-PID walk and reads its `ai_sessions[].connection_id` pin. Explicit `-SessionPath` / `-ConnectionPath` / `-SystemName -Client -User` still override; resolution falls through to sole-connection auto-default or DENIED. Spawns on demand on the target connection via `/oSESSION_MANAGER` (the only OK-code mechanism verified on S/4HANA 1909 kernel 754; `CreateSession` and bare `/o` no-op). Stdout last line: `ACQUIRED: path=

sessionNumber= connection= reused=` / `RELEASED: path=

connection=` / `NOT_FOUND` / `DENIED: ` (exit 1) / `ERROR: ` (exit 2). Auto-rebuilds a v1 registry on first call after upgrade with a `WARN: v1 registry detected` line. Shells out to `sap_session_broker_com.vbs` for every SAP-side operation because PowerShell 7+/.NET 5+ cannot bind the SAP GUI Scripting Engine directly (`Marshal::GetActiveObject` removed in .NET 5+; even 32-bit Windows PowerShell 5.1 fails to resolve the SAPGUI ProgID through the ROT). | | `shared/scripts/sap_session_broker_com.vbs` | **Internal helper for `sap_session_broker.ps1`** — not intended for direct calls by other skills. | SAP COM helper for the broker. VBScript run via 32-bit `cscript`. Single argv command + JSON-on-stdout protocol. **Multi-connection aware** (Phase 3.5): `INFO` returns ALL attached SAP connections (each with `connection_path` / `description` / `system_name` / `client` / `user` / `language` / `logon_id` + a `sessions[]` array); `SPAWN ` spawns on a SPECIFIC connection (drives `/n` + `/oSESSION_MANAGER` on that connection's anchor, returns the newcomer's path + `SessionNumber`); `RESET ` drives `/n` on a specific session (used by `release` to return to SAP Easy Access); `PROBE ` does a single-session `findById` + `Info` read (used by acquire's pre-allocation Easy-Access verification). Exit codes: 0 success, 1 usage error, 2 SAP-unreachable, 3 command-level failure (details in JSON `error` field). JSON output is one line per invocation — broker parses with `ConvertFrom-Json`. | | `shared/scripts/sap_activation_log.vbs` | **SE11 / DDIC GUI-scripting VBS only — do NOT include in SE38/SE37/SE24/SE91 (no equivalent menu in those transactions)** | Activation-log capture. Include via `ExecuteGlobal FSO.OpenTextFile("%%ACTIVATION_LOG_VBS%%",1).ReadAll()`. Functions: `CaptureActivationLog(oSess, sObjectName, sOutDir, kEnter, kBack)` → returns "" on failure or absolute path of saved log file on success; `ExtractTopActivationError(sLogPath)` → returns the top error line from the log (empty string if none). After Activate, when `sbar.MessageType = "E"` or `"A"`, call `CaptureActivationLog` then echo `ACTIVATION_LOG: ` and `ACTIVATION_ERROR: ` so the operator sees the specific failure instead of the generic "refer to log" SAP popup. Walks Utilities > Activation Log → Log > Save Local File via menu indices captured from a SAP GUI recording of the SE11 activation-log walk (`Record_SE11_ActivateErrorLog_01.vbs`, S/4HANA 1909). Re-record on releases that move the menus. The `Utilities > Activation Log` menu is a DDIC-worklist concept and exists ONLY in SE11; SE38/SE37/SE24/SE91 surface activation errors inline in the source-code editor + status bar (read via `wnd[0]/sbar.Text` — already done in those skills). | diff --git a/contributing/parallel_safe_session_attach.md b/contributing/parallel_safe_session_attach.md index c1bebf4..ae2d614 100644 --- a/contributing/parallel_safe_session_attach.md +++ b/contributing/parallel_safe_session_attach.md @@ -118,11 +118,15 @@ $content = $content -replace '%%SOME_PARAM%%','THE_SOME_PARAM' # ... other parameter substitutions ... # Phase 4.2 session-attach plumbing. -$sessionPath = '' # set to the parsed --session value if supplied -$content = $content -replace '%%SESSION_PATH%%', $sessionPath -$content = $content -replace '%%ATTACH_LIB_VBS%%','\scripts\sap_attach_lib.vbs' . '\scripts\sap_connection_lib.ps1' -$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp '{WORK_TEMP}' +# Prefer the parsed --session value; otherwise resolve this AI session's pin. +# BAKE it into the VBS rather than exporting $env:SAPDEV_SESSION_PATH here: this +# generator is usually a DIFFERENT process from the one that later runs cscript, +# so an env var set here never arrives (see gotcha 4). +$sessionPath = $ParsedSessionArg +if (-not $sessionPath) { $sessionPath = Get-SapCurrentSessionPath -WorkTemp '{WORK_TEMP}' } +$content = $content.Replace('%%SESSION_PATH%%', $sessionPath) +$content = $content -replace '%%ATTACH_LIB_VBS%%','\scripts\sap_attach_lib.vbs' [System.IO.File]::WriteAllText('{RUN_TEMP}\sap___run.vbs', $content, [System.Text.UnicodeEncoding]::new($false, $true)) ``` @@ -138,7 +142,7 @@ $env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp '{WORK_TEMP}' each other's `*_run.vbs` between write and `cscript` exec. See CLAUDE.md "Work Directory Configuration". -- `$sessionPath = ''` is intentional default — the helper auto-resolves via `SAPDEV_SESSION_PATH` → sole-connection → refuse. +- An empty `$sessionPath` is a valid outcome, not a bug — the helper then falls through to sole-connection → refuse. What is NOT acceptable is leaving it hardcoded to `''` while a pin exists: that discards the pin and hands the run to whatever GUI window happens to be open. - `Get-SapCurrentSessionPath` reads `session_registry.json`'s `ai_sessions[].connection_id` for this AI session (parent-PID walk), finds the matching connection block, returns a usable session path on it. Empty string when nothing resolves; the attach lib's sole-connection fallback or "refuse" path then takes over. - If the SKILL.md is wrapping a write-class skill that also includes `%%SESSION_LOCK_VBS%%`, leave that substitution in — it's complementary, not redundant. @@ -170,7 +174,14 @@ If you write a new bootstrap-style file that legitimately needs custom attach, a 1. **Don't inline the literal `%%SESSION_PATH%%` token as a sentinel comparison.** The PowerShell wrapper's `.Replace()` is global, so any occurrence of the literal token will be rewritten. If you need to detect "unsubstituted token," build the comparison string at runtime via `Chr(37) & Chr(37) & "SESSION_PATH" & Chr(37) & Chr(37)`. See `sap_gui_object_details.vbs` for the precedent (and the bug it originally hid). 2. **Include order matters when both attach-lib and session-lock are present.** Attach lib MUST load first because session-lock's pre-unlock popup sweep reads from `oSession`. The canonical pattern above gets this right. 3. **The helper handles ALL error paths.** Don't wrap `AttachSapSession(SESSION_PATH)` in your own `If oSession Is Nothing Then ...` — the helper has already `WScript.Quit 2`'d on failure. Adding your own block is dead code. -4. **`SAPDEV_SESSION_PATH` is set in PowerShell, read by cscript.** Process env vars cross the boundary, so the cscript child inherits it. Don't try to pass it as an argv arg — the helper specifically looks at the env var. +4. **`SAPDEV_SESSION_PATH` only reaches cscript if cscript is a CHILD OF THE SAME PowerShell process.** A process env var is inherited by children — it is *not* shared between sibling processes and it dies when the process that set it exits. Most SKILL.md files set it in the **generator** block (`...-Fill the tokens`) and then launch `cscript` from a **separate, later** block, so the variable is already gone: the helper skips Strategy 2 and silently falls through to the sole-connection default. That is not hypothetical — it is what let a run pinned to S4D/100 drive a GUI window on S4H/400 and download the wrong system's source (2026-08-06). **Fix the generator by baking the resolved path into `%%SESSION_PATH%%`** (Strategy 1 — it is a `Const` in the emitted VBS, so it survives any process boundary), and only rely on the env var when the same block that exports it also runs `cscript`. +5. **Declare the expected SAP system whenever the skill also talks RFC.** `Connect-SapRfc` and `AttachSapSession` resolve their target through two *different* chains (pin → GUI-active → default → sole-profile, vs. hint → env → sole-connection → refuse), so they can land on different systems while the skill believes it read one. Export the expectation in the block that launches `cscript`: + ```powershell + . '\scripts\sap_connection_lib.ps1' + Set-SapGuiTargetExpectation -WorkTemp '{WORK_TEMP}' | Out-Null # sets SAPDEV_EXPECT_SYSTEM/_CLIENT + & 'C:/Windows/SysWOW64/cscript.exe' //NoLogo '{RUN_TEMP}\..._run.vbs' + ``` + `AssertSapGuiTarget` in the attach lib then refuses (exit 2) any session that is not that system, instead of retargeting silently. Every attach — expectation or not — now also emits `GUI_TARGET: system=… client=… user=… path=… via=…`, so a skill's output always records which SAP system it actually drove. **Surface that line in the skill's report; never state a system you did not read off it.** --- diff --git a/plugins/sap-dev-core/shared/scripts/sap_attach_lib.vbs b/plugins/sap-dev-core/shared/scripts/sap_attach_lib.vbs index 6f75069..3c6ad1e 100644 --- a/plugins/sap-dev-core/shared/scripts/sap_attach_lib.vbs +++ b/plugins/sap-dev-core/shared/scripts/sap_attach_lib.vbs @@ -39,6 +39,37 @@ ' On failure: emits `ERROR: ` to stdout and calls WScript.Quit 2. ' Callers do NOT need their own error-handling block around the call. ' +' Whichever strategy wins, the helper then emits the SAP identity of the +' session it attached to: +' +' GUI_TARGET: system= client= user= path=<...> +' +' and, when the caller has declared an expected target, ENFORCES it. +' +' Target assertion (SAPDEV_EXPECT_SYSTEM / SAPDEV_EXPECT_CLIENT) +' -------------------------------------------------------------- +' A skill that reads over BOTH transports -- RFC (Connect-SapRfc) and GUI +' (this helper) -- resolves its SAP target twice, through two different +' chains. RFC follows pin -> GUI-active -> default -> sole-profile; +' this helper follows hint -> env -> sole-connection -> refuse. Those +' chains can disagree, and when they do the skill reads TWO DIFFERENT SAP +' SYSTEMS while believing it read one. +' +' Live incident (2026-08-06, S4D/100 vs S4H/400): /sap-se38 downloaded +' Z_EXCEPTION_1 through the GUI leg while the RFC leg read the pinned +' profile. The AI session was pinned to S4D/100 but the only attached GUI +' connection was S4H/400, so Strategy 3 below silently retargeted. Both +' systems host an independently-maintained Z_EXCEPTION_1; the two reads +' differed on 7 lines and nothing in either output said which system it +' came from. A check-fix-upload loop on that basis reverts live edits. +' +' So: when the wrapper sets SAPDEV_EXPECT_SYSTEM (and optionally +' SAPDEV_EXPECT_CLIENT) -- see Set-SapGuiTargetExpectation in +' sap_connection_lib.ps1, which reads the SAME profile Connect-SapRfc +' would pick -- a mismatch is a hard refusal, not a silent retarget. +' Unset = today's behaviour (identity still echoed), so this is additive +' for every VBS that has not been wired up yet. +' ' Why a helper rather than every skill rolling its own ' ---------------------------------------------------- ' The legacy idiom: @@ -74,6 +105,75 @@ ' from Chr() codes so no wrapper substitution can corrupt it. ' ============================================================================= +' ----------------------------------------------------------------------------- +' AssertSapGuiTarget(oSes, sVia) +' +' NOTE: no leading underscore. VBScript identifiers must start with a letter -- +' a `_`-prefixed Sub name is a compile error, and because this lib is pulled in +' via ExecuteGlobal the failure surfaces on the CALLER's ExecuteGlobal line, +' not here, which makes it needlessly hard to diagnose. +' +' Echo the SAP identity of the session we just attached to, then enforce the +' caller's declared expectation. Called from EVERY success path of +' AttachSapSession, so no strategy can return an unstamped session. +' +' Identity is read from GuiSession.Info (SystemName / Client / User) -- these +' are IDs, never localised display text, so the check is language-independent +' per shared/rules/language_independence_rules.md. +' +' Comparison is case-insensitive and trims blanks. Client is compared only +' when SAPDEV_EXPECT_CLIENT is set, so a caller can pin "the S4D system, any +' client" if that is genuinely what it wants. +' ----------------------------------------------------------------------------- +Sub AssertSapGuiTarget(oSes, sVia) + Dim sSid, sCli, sUsr, sPath + sSid = "" : sCli = "" : sUsr = "" : sPath = "" + On Error Resume Next + sPath = "" & oSes.Id + sSid = "" & oSes.Info.SystemName + sCli = "" & oSes.Info.Client + sUsr = "" & oSes.Info.User + On Error GoTo 0 + sSid = Trim(sSid) : sCli = Trim(sCli) : sUsr = Trim(sUsr) + + WScript.Echo "GUI_TARGET: system=" & sSid & " client=" & sCli & _ + " user=" & sUsr & " path=" & sPath & " via=" & sVia + + Dim oEnvShell, sExpSys, sExpCli + Set oEnvShell = CreateObject("WScript.Shell") + On Error Resume Next + sExpSys = oEnvShell.Environment("Process")("SAPDEV_EXPECT_SYSTEM") + sExpCli = oEnvShell.Environment("Process")("SAPDEV_EXPECT_CLIENT") + On Error GoTo 0 + sExpSys = Trim("" & sExpSys) + sExpCli = Trim("" & sExpCli) + + ' No expectation declared -> legacy behaviour (stamped, unenforced). + If sExpSys = "" And sExpCli = "" Then Exit Sub + + ' If the expectation is set but identity could not be read, refuse: an + ' unverifiable target is exactly the case this guard exists for. + If sSid = "" Then + WScript.Echo "ERROR: SAP GUI target could not be identified (Info.SystemName empty) " & _ + "but SAPDEV_EXPECT_SYSTEM=" & sExpSys & " was declared. Refusing to drive an unverified session." + WScript.Quit 2 + End If + + Dim bBad : bBad = False + If sExpSys <> "" And StrComp(sExpSys, sSid, 1) <> 0 Then bBad = True + If sExpCli <> "" And StrComp(sExpCli, sCli, 1) <> 0 Then bBad = True + + If bBad Then + WScript.Echo "ERROR: SAP GUI target mismatch. Expected " & sExpSys & "/" & sExpCli & _ + " but attached session " & sPath & " is " & sSid & "/" & sCli & "/" & sUsr & "." + WScript.Echo " The RFC leg of this skill targets " & sExpSys & "/" & sExpCli & _ + ", so continuing would read/write TWO DIFFERENT SAP SYSTEMS in one run." + WScript.Echo " Fix: open a SAP GUI session on " & sExpSys & "/" & sExpCli & _ + ", or run /sap-login --switch " & sSid & " to re-pin this AI session to the system the GUI is on." + WScript.Quit 2 + End If +End Sub + Function AttachSapSession(sHint) Dim oSAP, oApp On Error Resume Next @@ -121,6 +221,7 @@ Function AttachSapSession(sHint) On Error GoTo 0 If Not (oSes1 Is Nothing) Then WScript.Echo "INFO: attached to " & sCandidate & " (via explicit hint)" + AssertSapGuiTarget oSes1, "explicit-hint" Set AttachSapSession = oSes1 Exit Function End If @@ -146,6 +247,7 @@ Function AttachSapSession(sHint) On Error GoTo 0 If Not (oSes2 Is Nothing) Then WScript.Echo "INFO: attached to " & sEnv & " (via SAPDEV_SESSION_PATH env var)" + AssertSapGuiTarget oSes2, "session-path-env" Set AttachSapSession = oSes2 Exit Function End If @@ -156,14 +258,21 @@ Function AttachSapSession(sHint) End If ' --- Strategy 3: single-connection / single-session safe default -------- - ' Today's 99% case: exactly one SAP connection attached. With or without - ' a pin file, this is unambiguous and safe to auto-use. + ' Today's 99% case: exactly one SAP connection attached. Unambiguous about + ' WHICH session to drive -- but note it says nothing about whether that + ' session is the system the caller actually meant. "Sole" is not "right": + ' when the AI session is pinned to system A and the only GUI window is on + ' system B, this strategy hands back B. That is the 2026-08-06 cross-system + ' read described in the header, and it is why AssertSapGuiTarget runs + ' here too -- callers that declared SAPDEV_EXPECT_SYSTEM get a refusal + ' instead of another system's source. If oApp.Children.Count = 1 Then Dim oOnlyCon : Set oOnlyCon = oApp.Children(0) If oOnlyCon.Children.Count = 1 Then Dim oOnly : Set oOnly = Nothing For Each oOnly In oOnlyCon.Children : Exit For : Next WScript.Echo "INFO: attached to " & oOnly.Id & " (sole connection, sole session)" + AssertSapGuiTarget oOnly, "sole-connection" Set AttachSapSession = oOnly Exit Function End If diff --git a/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 b/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 index 41760e0..58d9ec8 100644 --- a/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 +++ b/plugins/sap-dev-core/shared/scripts/sap_connection_lib.ps1 @@ -1736,6 +1736,80 @@ function Get-SapCurrentSessionPath { return "$($entry.path)" } +function Set-SapGuiTargetExpectation { + <# + .SYNOPSIS + Declare which SAP system the GUI leg of this skill is allowed to drive, + by exporting SAPDEV_EXPECT_SYSTEM / SAPDEV_EXPECT_CLIENT for the VBS + attach helper (sap_attach_lib.vbs) to enforce. + .DESCRIPTION + A skill that reads over BOTH transports resolves its SAP target twice: + Connect-SapRfc walks pin -> GUI-active -> default -> sole-profile, while + AttachSapSession walks hint -> SAPDEV_SESSION_PATH -> sole-connection -> + refuse. Those two chains can land on DIFFERENT SYSTEMS, and neither leg + says which system it read -- so the skill silently mixes two systems. + + Live incident (2026-08-06): the AI session was pinned to S4D/100, the + only attached GUI window was S4H/400. Get-SapCurrentSessionPath + correctly returned '' (the pinned connection had no live block), and the + attach lib's sole-connection default then handed back S4H/400 anyway. + /sap-se38 downloaded S4H's Z_EXCEPTION_1 while the RFC readers read + S4D's -- 7 lines apart, same program name, no provenance in either + output. Re-uploading the "fixed" download would have reverted a year of + live edits on the other system. + + This function closes that hole from the PowerShell side: it resolves the + profile that Connect-SapRfc WOULD use (identical call, including + -PreferGuiActive) and publishes it as the expectation. The attach lib + then refuses any session that isn't it. When no profile resolves, the + expectation is cleared rather than guessed -- an unenforced attach is + the pre-existing behaviour, whereas a wrong expectation would block + legitimate work. + + Call this in the skill wrapper right next to the existing + `$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath ...` line. + .PARAMETER Clear + Remove both env vars instead of setting them (disables enforcement for + the rest of this process). + .OUTPUTS + The resolved profile (or $null). Also emits an INFO line to stderr so + the declared target is visible in the skill's log. + #> + param( + [string]$WorkTemp = '', + [string]$RuntimeDir = '', + [switch]$Clear + ) + + if ($Clear) { + $env:SAPDEV_EXPECT_SYSTEM = $null + $env:SAPDEV_EXPECT_CLIENT = $null + return $null + } + + $prof = $null + try { + $prof = Get-SapCurrentConnectionProfile -WorkTemp $WorkTemp -RuntimeDir $RuntimeDir -PreferGuiActive + } catch { $prof = $null } + + $sid = ''; $cli = '' + if ($prof) { $sid = "$($prof.system_name)".Trim(); $cli = "$($prof.client)".Trim() } + + if ([string]::IsNullOrWhiteSpace($sid)) { + # Nothing resolved (or a profile with a blank system_name -- which the + # safety gate already treats as fail-closed). Do NOT invent one. + $env:SAPDEV_EXPECT_SYSTEM = $null + $env:SAPDEV_EXPECT_CLIENT = $null + [Console]::Error.WriteLine("WARN: no SAP connection profile resolved; the GUI leg will run UNVERIFIED (it may attach to a different system than the RFC leg). Run /sap-login to pin a connection.") + return $null + } + + $env:SAPDEV_EXPECT_SYSTEM = $sid + $env:SAPDEV_EXPECT_CLIENT = $cli + [Console]::Error.WriteLine("INFO: GUI target expectation = $sid/$cli (matches the RFC leg); a GUI session on any other system will be refused.") + return $prof +} + function Get-SapLiveGuiConnections { <# .SYNOPSIS diff --git a/plugins/sap-dev-core/shared/scripts/sap_rfc_read_source.ps1 b/plugins/sap-dev-core/shared/scripts/sap_rfc_read_source.ps1 index 67cf917..bb7096b 100644 --- a/plugins/sap-dev-core/shared/scripts/sap_rfc_read_source.ps1 +++ b/plugins/sap-dev-core/shared/scripts/sap_rfc_read_source.ps1 @@ -13,9 +13,24 @@ # # Functions exposed: # Read-SapAbapSource -> [pscustomobject] { Status; Object; Type; SourceFile; -# Lines; Includes; Truncated; Error } +# Lines; Includes; Truncated; Error; +# System; Client; Host } # Get-SapIncludeTree -> string[] (include names) # +# FIDELITY (verified live 2026-08-06, S4H/400 + S4D/100, Z_EXCEPTION_1): +# RPY_PROGRAM_READ is AUTHORITATIVE. Byte-for-byte identical to what SE38 +# shows a developer -- 54/54 lines matched the SE38 AbapEditor download of +# the same program on the same system, including inline comments and +# multi-byte (Chinese) text. It is not a stale rendition and it does not +# normalise or truncate the stored source. +# +# The divergence originally reported against this reader was a CROSS-SYSTEM +# read, not a rendition difference: the RFC leg followed the AI-session pin +# (S4D/100) while the GUI leg attached to the only live SAP GUI window +# (S4H/400). Both systems host an independently-maintained Z_EXCEPTION_1. +# Hence System/Client/Host on the result + the source.meta.json sidecar: +# ALWAYS reconcile provenance before attributing a text delta to a reader. +# # Mechanics: # program / module pool / FUGR-main / include : RPY_PROGRAM_READ # function module : RPY_FUNCTIONMODULE_READ_NEW @@ -62,6 +77,37 @@ function _ReadRfcTableFirstColumn($tab) { return ,$lines } +function _RfcDestIdentity($dest) { + # Which SAP system did this source actually come from? Read straight off + # the live NCo destination -- never off the caller's intent. A source file + # with no provenance is how the 2026-08-06 cross-system read stayed + # invisible: the GUI leg had downloaded S4H/400's Z_EXCEPTION_1 while this + # reader returned S4D/100's, and neither output named its system. + # Read RfcDestination.SystemAttributes -- the identity NCo got back from the + # live logon. NOT RfcDestination.SystemID: that is the configured R3NAME and + # is EMPTY for a direct -Server/-Sysnr connection (verified S4H 2026-08-06), + # which would silently produce a blank stamp. There is no .Attributes member + # on RfcDestination at all. + # Best-effort: an attribute read that fails degrades to blanks, never throws. + $id = @{ System = ''; Client = ''; User = ''; Host = '' } + try { + $a = $dest.SystemAttributes + if ($a) { + try { $id.System = "$($a.SystemID)".Trim() } catch { } + try { $id.Client = "$($a.Client)".Trim() } catch { } + try { $id.User = "$($a.User)".Trim() } catch { } + try { $id.Host = "$($a.PartnerHost)".Trim() } catch { } + } + } catch { } + # Fall back to the destination's own config for anything still blank. + foreach ($pair in @(@('Client','Client'), @('User','User'), @('Host','AppServerHost'))) { + if ([string]::IsNullOrWhiteSpace($id[$pair[0]])) { + try { $id[$pair[0]] = "$($dest.($pair[1]))".Trim() } catch { } + } + } + return $id +} + function _RfcRowExists($dest, $table, $where, $keyField) { # Cheap existence probe via RFC_READ_TABLE (guarded by New-RfcReadTable). try { @@ -157,9 +203,14 @@ function Read-SapAbapSource { # BOM, PS 5.1 would misread multibyte (e.g. Japanese) source as ANSI. $utf8NoBom = New-Object System.Text.UTF8Encoding $true + # System/Client/Host record WHICH SAP system the source came from. Callers + # that compare this reader against the GUI download leg (/sap-check-abap, + # /sap-fix-abap, /sap-git serialize) must compare provenance before they + # compare text -- see the sidecar written next to SourceFile below. $result = [pscustomobject]@{ Status = 'ERROR'; Object = $Name.ToUpper(); Type = $Type SourceFile = ''; Lines = 0; Includes = @(); Truncated = $false; Error = '' + System = ''; Client = ''; Host = '' } if ($Type -in @('class','interface')) { @@ -178,6 +229,11 @@ function Read-SapAbapSource { try { $obj = $result.Object + $ident = _RfcDestIdentity $dest + $result.System = $ident.System + $result.Client = $ident.Client + $result.Host = $ident.Host + # Existence pre-check -> clean NOT_FOUND (independent of RPY exceptions). if ($Type -eq 'fm') { if (-not (_RfcRowExists $dest 'TFDIR' "FUNCNAME = '$obj'" 'FUNCNAME')) { $result.Status = 'NOT_FOUND'; return $result } @@ -201,6 +257,27 @@ function Read-SapAbapSource { $result.SourceFile = $srcFile $result.Lines = @($allLines).Count + # Provenance sidecar. Deliberately a SEPARATE file: a header comment + # inside source.txt would corrupt the very thing callers re-upload. + # Any consumer diffing this against a GUI download must check that the + # two agree on system+client BEFORE treating a text delta as a real + # change (2026-08-06 cross-system read). + try { + $meta = [ordered]@{ + schema = 'sapdev.sourceread/1' + reader = 'RPY_PROGRAM_READ' + object = $obj + type = $Type + system = $result.System + client = $result.Client + host = $result.Host + lines = $result.Lines + read_at_utc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $metaFile = Join-Path $OutDir 'source.meta.json' + [System.IO.File]::WriteAllText($metaFile, ($meta | ConvertTo-Json), (New-Object System.Text.UTF8Encoding $false)) + } catch { } + $incOut = @() if ($WithIncludes -and $Type -ne 'fm' -and $includes -and @($includes).Count -gt 0) { $list = @($includes | Where-Object { $_ -and "$_".Trim() -ne '' } | ForEach-Object { "$_".Trim() } | Select-Object -Unique) diff --git a/plugins/sap-dev-core/skills/sap-se24/references/sap_se24_check_and_download.vbs b/plugins/sap-dev-core/skills/sap-se24/references/sap_se24_check_and_download.vbs index fdf5f17..a2f807a 100644 --- a/plugins/sap-dev-core/skills/sap-se24/references/sap_se24_check_and_download.vbs +++ b/plugins/sap-dev-core/skills/sap-se24/references/sap_se24_check_and_download.vbs @@ -5,7 +5,10 @@ ' Generated by the sap-se24 skill (check-and-fix mode). ' Attaches to an existing SAP GUI session, navigates to SE24, opens the ' class in display mode (source-code-based view), runs a syntax check -' (Ctrl+F2), reports any errors, then downloads the source via GetLineText. +' (Ctrl+F2), reports any errors, then downloads the source via the editor's +' Utilities > More Utilities > Upload/Download > Download menu (a SAP-GUI file +' dialog -- NOT the AbapEditor.GetLineText loop that se38/se37 use, so the +' 1-based/padding gotcha documented there does not apply here). ' ' Tokens replaced at run time: ' %%CLASS_NAME%% ABAP class name e.g. "ZCL_HK_TEST001" diff --git a/plugins/sap-dev-core/skills/sap-se37/SKILL.md b/plugins/sap-dev-core/skills/sap-se37/SKILL.md index a8c8a6b..d65455e 100644 --- a/plugins/sap-dev-core/skills/sap-se37/SKILL.md +++ b/plugins/sap-dev-core/skills/sap-se37/SKILL.md @@ -1330,19 +1330,38 @@ oSession.findById("wnd[0]/usr/tabsFUNC_TAB_STRIP/tabpSOURCE").select WScript.Sleep 1500 Dim oShell Set oShell = oSession.findById("wnd[0]/usr/tabsFUNC_TAB_STRIP/tabpSOURCE/ssubSCREEN_HEADER:SAPLEDITOR_START:8430/cntlEDITOR/shellcont/shell") -' Read lines via GetLineText(n), 0-indexed, stop on error +' GetLineText is 1-BASED and returns "" (no error) past the end of the source, +' so "start at 0 and stop on error" writes a phantom blank first line and then +' pads to the loop bound. Read from 1 and truncate at the last non-blank line. Dim oFSO : Set oFSO = CreateObject("Scripting.FileSystemObject") Dim oFile : Set oFile = oFSO.CreateTextFile("{RUN_TEMP}\fm_src_from_sap.txt", True, True) On Error Resume Next -Dim i : For i = 0 To 500 - Dim s : s = oShell.GetLineText(i) +Dim aSrc() : ReDim aSrc(1023) +Dim i, s, nLast, nBlank +nLast = 0 : nBlank = 0 +For i = 1 To 99999 + Err.Clear + s = oShell.GetLineText(i) If Err.Number <> 0 Then Err.Clear : Exit For - oFile.WriteLine s + If i > UBound(aSrc) Then ReDim Preserve aSrc(UBound(aSrc) * 2 + 1) + aSrc(i) = s + If Len(Trim(s)) = 0 Then + nBlank = nBlank + 1 + If nBlank >= 500 Then Exit For + Else + nBlank = 0 : nLast = i + End If +Next +For i = 1 To nLast + oFile.WriteLine aSrc(i) Next oFile.Close +WScript.Echo "SOURCE_LINES: " & nLast ``` The resulting file is UTF-16 LE (because `CreateTextFile(..., True, True)` writes Unicode). +Its line N corresponds to SAP source line N — the phantom first line is gone, so +syntax-check line numbers index the file directly. ### Fix the source and re-upload @@ -1378,15 +1397,21 @@ $workTemp = 'THE_WORK_TEMP' $content = [System.IO.File]::ReadAllText("$skillDir\references\sap_se37_check_and_download.vbs", [System.Text.Encoding]::UTF8) $content = $content -replace '%%FM_NAME%%', $fmName $content = $content -replace '%%OUTPUT_FILE%%', $outFile -# Phase 3.5 session-attach plumbing. -$sessionPath = '' -$content = $content -replace '%%SESSION_PATH%%', $sessionPath +# Phase 3.5 session-attach plumbing. BAKE the resolved session path into the +# VBS (%%SESSION_PATH%% = attach Strategy 1) rather than exporting it as +# $env:SAPDEV_SESSION_PATH here: this generator is a SEPARATE process from the +# one that later launches cscript, so an env var set here dies with it and the +# attach lib silently fell through to its sole-connection default (Strategy 3) +# -- which is how a run pinned to one SAP system read another one's source +# (2026-08-06). A baked-in const survives the process boundary; an empty value +# still falls through exactly as before. +. '\scripts\sap_connection_lib.ps1' +$sessionPath = Get-SapCurrentSessionPath -WorkTemp $workTemp +$content = $content.Replace('%%SESSION_PATH%%', $sessionPath) $content = $content -replace '%%ATTACH_LIB_VBS%%', '\scripts\sap_attach_lib.vbs' $content = $content -replace '%%SYNTAX_CHECK_LIB_VBS%%', '\scripts\sap_syntax_check_lib.vbs' -. '\scripts\sap_connection_lib.ps1' -$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp $workTemp [System.IO.File]::WriteAllText("{RUN_TEMP}\sap_se37_check_and_download_run.vbs", $content, [System.Text.UnicodeEncoding]::new($false, $true)) -Write-Host 'Done' +Write-Host ("Done (session_path='" + $sessionPath + "')") ``` | Placeholder | Value | @@ -1415,6 +1440,14 @@ system / client: ```powershell $shared = '\scripts' $out = '{RUN_TEMP}\THE_FM_NAME_from_sap.txt' # the path SAP GUI will write +# 0. Declare which SAP system the GUI leg may drive. MUST happen in THIS block: +# the attach lib reads it from the process environment, and cscript is a +# child of THIS PowerShell, not of the generator block above. It resolves the +# same profile Connect-SapRfc uses, so if the GUI is sitting on a different +# system than the RFC readers target, the attach refuses loud instead of +# downloading another system's source under this FM's name. +. "$shared\sap_connection_lib.ps1" +Set-SapGuiTargetExpectation -WorkTemp '{WORK_TEMP}' | Out-Null # 1. Pre-check the allow-list (read-only; informational + lets us skip the watcher). & "$shared\sap_gui_security_precheck.ps1" -Path $out -Access w -System 'THE_SID' -Client 'THE_CLIENT' -Transaction 'SE37' | Out-Host $allowed = ($LASTEXITCODE -eq 0) diff --git a/plugins/sap-dev-core/skills/sap-se37/references/sap_se37_check_and_download.vbs b/plugins/sap-dev-core/skills/sap-se37/references/sap_se37_check_and_download.vbs index 4ba0e14..8088912 100644 --- a/plugins/sap-dev-core/skills/sap-se37/references/sap_se37_check_and_download.vbs +++ b/plugins/sap-dev-core/skills/sap-se37/references/sap_se37_check_and_download.vbs @@ -185,7 +185,7 @@ On Error GoTo 0 oSession.findById("wnd[0]/usr/tabsFUNC_TAB_STRIP/tabpSOURCE").select WScript.Sleep 1500 -' Read source via AbapEditor GetLineText (0-indexed) +' Read source via AbapEditor GetLineText (1-indexed -- see the loop below) On Error Resume Next Dim oShell Set oShell = oSession.findById("wnd[0]/usr/tabsFUNC_TAB_STRIP/tabpSOURCE/ssubSCREEN_HEADER:SAPLEDITOR_START:8430/cntlEDITOR/shellcont/shell") @@ -200,19 +200,55 @@ Set oFSO = CreateObject("Scripting.FileSystemObject") Dim oFile Set oFile = oFSO.CreateTextFile(OUTPUT_FILE, True, True) ' Unicode = True -> UTF-16 LE -Dim i -For i = 0 To 9999 - Dim sLine - sLine = oShell.GetLineText(i) +' AbapEditor.GetLineText is 1-BASED, and an out-of-range index returns "" with +' NO error raised. Verified live 2026-08-06 (SAP GUI 7700 / S/4HANA): index 0 +' is a phantom empty line, the source occupies 1..N, and N+1..9999 all return +' "" silently. So the previous `For i = 0 To 9999 ... If Err.Number <> 0 Then +' Exit For` never terminated early -- it wrote one phantom blank line, the +' source, then thousands of blank padding lines. That inflated every downstream +' token cost and left callers guessing where the source ended. The phantom line +' also shifted everything by one, so SAP's 1-based syntax-check line numbers did +' not match the file a fix loop edited. +' +' Read from 1, track the last non-blank line, write only up to it. The +' blank-run stop keeps this to a few hundred COM round-trips instead of 10,000; +' 500 consecutive BLANK lines do not occur inside real ABAP source (comment +' lines are not blank). SOURCE_LINES makes the boundary explicit. +Const SRC_BLANK_RUN_STOP = 500 +Const SRC_HARD_CAP = 99999 + +Dim aSrc() +ReDim aSrc(1023) +Dim iSrc, sSrcLine, nSrcLast, nSrcBlank, bSrcCapHit +nSrcLast = 0 : nSrcBlank = 0 : bSrcCapHit = False +For iSrc = 1 To SRC_HARD_CAP + Err.Clear + sSrcLine = oShell.GetLineText(iSrc) If Err.Number <> 0 Then Err.Clear Exit For End If - oFile.WriteLine sLine + If iSrc > UBound(aSrc) Then ReDim Preserve aSrc(UBound(aSrc) * 2 + 1) + aSrc(iSrc) = sSrcLine + If Len(Trim(sSrcLine)) = 0 Then + nSrcBlank = nSrcBlank + 1 + If nSrcBlank >= SRC_BLANK_RUN_STOP Then Exit For + Else + nSrcBlank = 0 + nSrcLast = iSrc + End If + If iSrc = SRC_HARD_CAP Then bSrcCapHit = True +Next +For iSrc = 1 To nSrcLast + oFile.WriteLine aSrc(iSrc) Next oFile.Close On Error GoTo 0 +If bSrcCapHit Then + WScript.Echo "WARN: hit the " & SRC_HARD_CAP & "-line cap; downloaded source may be truncated." +End If +WScript.Echo "SOURCE_LINES: " & nSrcLast WScript.Echo "INFO: Source downloaded to: " & OUTPUT_FILE ' ------ 6. Final result marker ---------------------------------------------- diff --git a/plugins/sap-dev-core/skills/sap-se38/SKILL.md b/plugins/sap-dev-core/skills/sap-se38/SKILL.md index b4a5fe8..ab037e9 100644 --- a/plugins/sap-dev-core/skills/sap-se38/SKILL.md +++ b/plugins/sap-dev-core/skills/sap-se38/SKILL.md @@ -1223,15 +1223,21 @@ $runTemp = 'THE_RUN_TEMP' # per-run scratch dir -- where the generated wrap $content = [System.IO.File]::ReadAllText("$skillDir\references\sap_se38_check_and_download.vbs", [System.Text.Encoding]::UTF8) $content = $content -replace '%%PROGRAM_NAME%%', $pgmName $content = $content -replace '%%OUTPUT_FILE%%', $outFile -# Phase 3.5 session-attach plumbing. -$sessionPath = '' -$content = $content -replace '%%SESSION_PATH%%', $sessionPath +# Phase 3.5 session-attach plumbing. BAKE the resolved session path into the +# VBS (%%SESSION_PATH%% = attach Strategy 1) rather than exporting it as +# $env:SAPDEV_SESSION_PATH here: this generator is a SEPARATE process from the +# one that later launches cscript, so an env var set here dies with it and the +# attach lib silently fell through to its sole-connection default (Strategy 3) +# -- which is how a run pinned to one SAP system read another one's source +# (2026-08-06). A baked-in const survives the process boundary; an empty value +# still falls through exactly as before. +. '\scripts\sap_connection_lib.ps1' +$sessionPath = Get-SapCurrentSessionPath -WorkTemp $workTemp +$content = $content.Replace('%%SESSION_PATH%%', $sessionPath) $content = $content -replace '%%ATTACH_LIB_VBS%%', '\scripts\sap_attach_lib.vbs' $content = $content -replace '%%SYNTAX_CHECK_LIB_VBS%%', '\scripts\sap_syntax_check_lib.vbs' -. '\scripts\sap_connection_lib.ps1' -$env:SAPDEV_SESSION_PATH = Get-SapCurrentSessionPath -WorkTemp $workTemp [System.IO.File]::WriteAllText("$runTemp\sap_se38_check_and_download_run.vbs", $content, [System.Text.UnicodeEncoding]::new($false, $true)) -Write-Host 'Done' +Write-Host ("Done (session_path='" + $sessionPath + "')") ``` | Placeholder | Value | @@ -1261,6 +1267,14 @@ system / client: ```powershell $shared = '\scripts' $out = '{RUN_TEMP}\THE_PROGRAM_NAME_from_sap.txt' # the path SAP GUI will write +# 0. Declare which SAP system the GUI leg may drive. MUST happen in THIS block: +# the attach lib reads it from the process environment, and cscript is a +# child of THIS PowerShell, not of the generator block above. It resolves the +# same profile Connect-SapRfc uses, so if the GUI is sitting on a different +# system than the RFC readers target, the attach refuses loud instead of +# downloading another system's source under this program's name. +. "$shared\sap_connection_lib.ps1" +Set-SapGuiTargetExpectation -WorkTemp '{WORK_TEMP}' | Out-Null # 1. Pre-check the allow-list (read-only; informational + lets us skip the watcher). & "$shared\sap_gui_security_precheck.ps1" -Path $out -Access w -System 'THE_SID' -Client 'THE_CLIENT' -Transaction 'SE38' | Out-Host $allowed = ($LASTEXITCODE -eq 0) @@ -1288,27 +1302,51 @@ if ($watcher) { $watcher | Wait-Process -Timeout 45 -ErrorAction SilentlyContinu | `RESULT: SYNTAX_ERRORS` | Errors found (shown above the RESULT line) | Proceed to Step B | | `ERROR:` | Fatal failure | Show full output, stop | +Two lines earlier in the output are load-bearing — **read them, don't skip them**: + +| Line | Meaning | +|---|---| +| `GUI_TARGET: system= client= user= path=<...>` | **Which SAP system this download actually came from.** Quote it whenever you report the source, and re-check it before any re-upload. `ERROR: SAP GUI target mismatch` here means the GUI was on a different system than the RFC leg — stop, do not fix, do not upload. | +| `SOURCE_LINES: ` | Exact number of source lines written. The file has no padding and no phantom first line, so **file line N == SAP source line N** — syntax-check line numbers index it directly. | + --- ## Step B — Analyze and Fix Source The source was downloaded to `{RUN_TEMP}\_from_sap.txt` (UTF-16 LE). -> **CAVEAT — `_from_sap.txt` may differ structurally from the disk source.** -> The download uses `AbapEditor.GetLineText(i)` which reads what the editor -> *displays*, not what is stored. SAP applies pretty-printer formatting -> between storage and display. Verified divergence: TYPES declared inside -> a local class PUBLIC SECTION can appear at PROGRAM scope in the -> `_from_sap.txt` download (would break re-deploy if used as a basis). +> **CHECK THE SYSTEM BEFORE YOU CHECK THE TEXT.** +> `_from_sap.txt` comes from whatever SAP system the GUI session is on; +> the RFC readers (`/sap-check-abap`, `/sap-fix-abap`, `/sap-git serialize`, +> `Read-SapAbapSource`) come from whatever profile `Connect-SapRfc` resolves. +> Those are two independent resolution chains and they *can* land on +> different systems. Confirm `GUI_TARGET:` here equals the `system`/`client` +> in the RFC read's `source.meta.json` before treating any text delta as a +> real difference. > -> **Rule:** if a disk copy of the source exists (e.g. the original -> `.abap` from the spec/build pipeline), apply your fixes -> there instead and re-deploy. Use `_from_sap.txt` only as a *reference* -> for what the live system has — never as the deploy basis when a -> structurally-correct disk copy is available. +> Verified live 2026-08-06 (S4D/100 vs S4H/400, `Z_EXCEPTION_1`): both systems +> hosted an independently-maintained program of that name, 7 lines apart. The +> GUI leg read S4H's and the RFC leg read S4D's in the same run, and neither +> output named its system. Re-uploading the "fixed" download would have +> reverted a year of live edits on the other system. The attach lib now +> refuses this outright when the wrapper declares an expected target — but the +> refusal only fires if `Set-SapGuiTargetExpectation` ran in the same block as +> `cscript` (see the Execute step above). +> +> **On fidelity:** once both legs are on the same system they agree +> byte-for-byte — a 54-line diff of this download against `RPY_PROGRAM_READ` +> on S4H/400 was 0 lines different, multi-byte comments included. There is no +> general pretty-printer rewrite between storage and display, and +> `RPY_PROGRAM_READ` is not a stale or lossy rendition; treat both readers as +> authoritative *for the system they read*. > -> A more robust download path via RFC `RPY_PROGRAM_READ` is on the -> roadmap; until then, prefer disk-copy editing. +> **Known structural exception:** TYPES declared inside a local class +> `PUBLIC SECTION` have been observed at PROGRAM scope in a `_from_sap.txt` +> download. That one is a real editor-vs-storage divergence and would break a +> re-deploy. So: if a disk copy of the source exists (e.g. the original +> `.abap` from the spec/build pipeline), apply fixes there and +> re-deploy; use `_from_sap.txt` as the deploy basis only when no +> structurally-correct disk copy is available. **1. Read the file:** ```powershell diff --git a/plugins/sap-dev-core/skills/sap-se38/references/sap_se38_check_and_download.vbs b/plugins/sap-dev-core/skills/sap-se38/references/sap_se38_check_and_download.vbs index f9bd138..380707a 100644 --- a/plugins/sap-dev-core/skills/sap-se38/references/sap_se38_check_and_download.vbs +++ b/plugins/sap-dev-core/skills/sap-se38/references/sap_se38_check_and_download.vbs @@ -184,7 +184,7 @@ End If Err.Clear On Error GoTo 0 -' Read source via AbapEditor GetLineText (0-indexed) +' Read source via AbapEditor GetLineText (1-indexed -- see the loop below) On Error Resume Next Dim oShell Set oShell = oSession.findById("wnd[0]/usr/cntlEDITOR/shellcont/shell") @@ -199,19 +199,55 @@ Set oFSO = CreateObject("Scripting.FileSystemObject") Dim oFile Set oFile = oFSO.CreateTextFile(OUTPUT_FILE, True, True) ' Unicode = True -> UTF-16 LE -Dim i -For i = 0 To 9999 - Dim sLine - sLine = oShell.GetLineText(i) +' AbapEditor.GetLineText is 1-BASED, and an out-of-range index returns "" with +' NO error raised. Verified live 2026-08-06 (SAP GUI 7700 / S/4HANA): index 0 +' is a phantom empty line, the source occupies 1..N, and N+1..9999 all return +' "" silently. So the previous `For i = 0 To 9999 ... If Err.Number <> 0 Then +' Exit For` never terminated early -- for a 54-line program it wrote 10,001 +' lines: one phantom blank, the source, then 9,946 blank padding lines. That +' inflated every downstream token cost and left callers guessing where the +' source ended. The phantom line also shifted everything by one, so SAP's +' 1-based syntax-check line numbers did not match the file a fix loop edited. +' +' Read from 1, track the last non-blank line, write only up to it. The +' blank-run stop keeps this to a few hundred COM round-trips instead of 10,000; +' 500 consecutive BLANK lines do not occur inside real ABAP source (comment +' lines are not blank). SOURCE_LINES makes the boundary explicit. +Const SRC_BLANK_RUN_STOP = 500 +Const SRC_HARD_CAP = 99999 + +Dim aSrc() +ReDim aSrc(1023) +Dim iSrc, sSrcLine, nSrcLast, nSrcBlank, bSrcCapHit +nSrcLast = 0 : nSrcBlank = 0 : bSrcCapHit = False +For iSrc = 1 To SRC_HARD_CAP + Err.Clear + sSrcLine = oShell.GetLineText(iSrc) If Err.Number <> 0 Then Err.Clear Exit For End If - oFile.WriteLine sLine + If iSrc > UBound(aSrc) Then ReDim Preserve aSrc(UBound(aSrc) * 2 + 1) + aSrc(iSrc) = sSrcLine + If Len(Trim(sSrcLine)) = 0 Then + nSrcBlank = nSrcBlank + 1 + If nSrcBlank >= SRC_BLANK_RUN_STOP Then Exit For + Else + nSrcBlank = 0 + nSrcLast = iSrc + End If + If iSrc = SRC_HARD_CAP Then bSrcCapHit = True +Next +For iSrc = 1 To nSrcLast + oFile.WriteLine aSrc(iSrc) Next oFile.Close On Error GoTo 0 +If bSrcCapHit Then + WScript.Echo "WARN: hit the " & SRC_HARD_CAP & "-line cap; downloaded source may be truncated." +End If +WScript.Echo "SOURCE_LINES: " & nSrcLast WScript.Echo "INFO: Source downloaded to: " & OUTPUT_FILE ' ------ 6. Final result marker ----------------------------------------------