Skip to content

adt-rfc-bridge: a stock Eclipse project reaches an HTTP backend over RFC - #2

Merged
oisee merged 20 commits into
mainfrom
feat/adt-rfc-bridge
Sep 16, 2026
Merged

oisee merged 20 commits into
mainfrom
feat/adt-rfc-bridge

Conversation

@oisee

@oisee oisee commented Sep 16, 2026

Copy link
Copy Markdown
Owner

A Custom Application Server project in Eclipse does not open an HTTP port. It
logs on over RFC on the gateway port and tunnels every ADT request inside one
RFC call, SADT_REST_RFC_ENDPOINT. This adds cmd/adt-rfc-bridge: it
terminates that RFC conversation, unwraps the HTTP exchange, forwards it to
--backend, and wraps the answer. Pointed at a real system it proves the
transport; pointed at an HTTP façade it proves the façade.

Measured end to end against a real system and against the sibling
open-steamgate: logon, discovery, identity, the repository tree expanded,
a source opened, unit-test metadata and check runs — all answered.

Four things each refuse in silence, and each is a commit here:

  • The dictionary bootstrap. Eclipse will not call a function it has not
    been described, so RFC_GET_FUNCTION_INTERFACE, DDIF_FIELDINFO_GET and
    RFC_GET_STRUCTURE_DEFINITION are answered from a stated type graph — the
    interface, not a bundled dictionary — and in the function's own parameter
    order.
  • SAP Binary XML. The payload is not the text xRFC the rest of the
    protocol uses. internal/bxml reads and writes the token stream (name
    references are index-plus-two; lengths are byte counts written as UTF-8
    scalars), verified by round-tripping every document of a captured session.
  • The response record header. Responses wear one of two gateway shapes;
    the wrong one is refused at the CPIC receive (CMRCV) before the content is
    read. The communication and connection index a client verifies are answered,
    not echoed.
  • The CSRF dance. Over RFC the client has no HTTP session, so the bridge
    owns the session, its cookies and its token, or every read works and the
    first write is a 403. Ported from vsp pkg/adt, narrowed to retry only an
    explicit CSRF refusal and to treat a backend that mints no token as one
    without CSRF.

An https backend is reached by naming its certificate (ca_file); there is
deliberately no option to skip verification.

The protocol facts are written up in the sibling repo, docs/adt-over-rfc.md.

🤖 Generated with Claude Code

oisee and others added 19 commits September 9, 2026 01:06
The NI framing, the bounds-checked byte reader/writer and the
framing-aware proxy are the parts of this module that are not RFC: a
second protocol on the same transport wants all three, and DIAG
(open-diag-go-pro) is that protocol. Moved out of internal/ with their
tests; import paths change from internal/ni to ni, and likewise for wire
and sniffer. Nothing else does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162uKF31Qeg14pwMwj4dmts
A real Eclipse ADT session was captured between the client and an AS ABAP
1909 sandbox, on the gateway port. Eleven conversations, all the same shape:
the 64-byte gateway record and its acknowledgement, F_INITIALIZE with an
80-byte reply, F_SET_PARTNER_LU_NAME answered by silence, F_ALLOCATE with
its reply, and then F_SAP_SEND carrying the calls.

Seven of those frames are now a fixture and a test replays them against
ServeConscious through a pipe, asserting that we answer what the system
answered. It fails, and the failure is the specification.

It already found one thing and fixed it. The gateway acknowledgement does
not echo the client's protocol level: a client offering "1100" and a client
offering "4103" were both answered "4103", over fourteen conversations. We
were echoing. Four bytes differ between request and reply and we were
patching two of them.

It now fails one exchange later, at F_INITIALIZE, which ServeConscious does
not implement — it knows the 0x06 0x03 CPIC init and not the 0x06 0x01
APPC verb Eclipse opens with. The reply is within reach: across eleven
conversations seventy-four of its eighty bytes are identical, and the six
that vary are four digits of the conversation id at offsets 40..47 and a
two-byte counter. ServeConscious already reads a conversation id from those
same offsets in the branch it does implement.

The fixture's bytes are as captured except that addresses, host and user
names are replaced by documentation values of the same length, so offsets
are unchanged. This repository is public and a capture is not a fixture
until nobody's name is in it.

The branch is red on purpose: the test is the work order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
Seven frames, byte for byte, against a real Eclipse conversation captured
from the wire. The test that was the work order now passes.

Three verbs were missing and each turned out to be the client's own bytes
with a few changes, the same changes in all eleven captured conversations:

  gateway record   the protocol level is the gateway's own, not an echo —
                   "1100" and "4103" are both answered "4103"
  F_INITIALIZE     the first eighty bytes back, a level byte raised, eight
                   spaces replaced by a conversation id we choose, and the
                   client's sequence counter copied over the 0xffff at the
                   end
  F_ALLOCATE       the request back with two status bytes set, the same
                   protocol level written where the client left zeroes, and
                   that sequence put back again

F_SET_PARTNER_LU_NAME is answered by silence, which the capture shows and
the test now requires: a server that replied there would be wrong in a way
nothing else would catch.

The conversation id is the one field we choose rather than match, so the
test masks it and checks its shape instead — eight ASCII digits. A test that
accepted anything there would accept an empty one.

Twenty-five packages green, vet clean. Next is the first F_SAP_SEND, which
is where the calls start and where the real work begins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
A server has nobody to ask what it is being sent — the client is the one
calling — so the shape of SADT_REST_RFC_ENDPOINT is stated here rather than
fetched. It was measured with RFC_METADATA_GET(DEEP) against a real system
and it is small enough to read: a request is a line, a table of headers and
a body, which is what an HTTP request is. Describing that is describing an
interface, not copying a dictionary, and no length is carried because
nothing in it is fixed-width.

Two tests put a whole exchange through the codec and back. The body is
binary on purpose: an xstring that round-trips as text would pass a weaker
test and fail on the first ADT answer, which is XML with a byte order mark
more often than not.

Two things the graph needed that the measurement does not mention, both
found by the codec refusing it. A scalar field must say so — the dispatch
reads Reference.Kind, and a field that leaves it empty is taken for a
reference to a type with no name, which fails a long way from the cause. And
the reference must repeat the field's internal type, because the validator
compares them; when they disagree it reports "xRFC scalar type g is not
implemented", which is not what went wrong. Both are noted where they are,
so the next reader does not spend the same twenty minutes.

Twenty-five packages green, vet clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…rry one

Two gaps stood between a decoded Eclipse request and an answer, and both are
now closed with a test that failed first.

The name of an xRFC parameter is not on the wire. EncodeCutFunctionRequest
checks it and then writes both boundaries with no value at all, so the only
place it survives is the root element of the XML between them. The server was
reading the boundary and getting "", which cannot tell REQUEST from anything
else and starts to matter the moment a function has two parameters. It now
reads the XML, and if a boundary does carry a name that disagrees, that is an
error rather than a preference.

And a response had no way to carry one. Response held exports and tables,
which is everything a classic function needs and nothing a recursive one
does. SADT_REST_RFC_ENDPOINT exports a structure holding a structure, a table
and an xstring; that travels as XML between two empty 0x3c02 boundaries in
0x3c05 chunks, exactly as the request arrives. Both encoders learned it,
including the S4 one — which is the one that matters, because it is the path
ServeConscious takes and therefore the path Eclipse takes.

Twenty-five packages green, vet clean. What is left is the handler itself:
unwrap REQUEST into an http.Request, call a backend, wrap what comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
The handler unwraps SADT_REST_RFC_ENDPOINT into an http.Request, makes it
against a backend and wraps what comes back. It has no opinion about ADT —
everything that knows what /sap/bc/adt means stays behind the backend URL,
which is what lets the same binary prove two different things.

Pointed at a real system it proves the transport, because the backend is
known good. Pointed at an implementation it proves the implementation,
because the transport already is. Two unknowns that would otherwise hide
inside each other, separated by one flag, and the flag is there from the
first line rather than added once that lesson has been paid for again.

Details that are easy to get wrong and are tested rather than hoped: the
query survives the URI, hop-by-hop headers are dropped because the
connection they belonged to never existed here, Host is set rather than
sent, and the status code travels as text because the dictionary calls it a
short string and the codec refuses an integer.

cmd/adt-rfc-bridge is the binary, and it refuses to start without a backend
rather than guessing one.

Twenty-five packages green, vet clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
On a real system the logon happens in the RFC conversation and the ICF call
inherits it: by the time a request reaches /sap/bc/adt it is already
somebody. A bridge is an outsider to that. It hands the exchange to an HTTP
backend that never saw the RFC logon, so without credentials the answer is
401 and nothing else — which is exactly what a curl against that system
returned this morning, and which I had walked past.

Credentials come from a JSON file, or from the environment for the two
secret fields. Deliberately not from a flag: an argument is visible to every
user on the machine through ps, and a password that has been in a process
list has been published.

They are optional, because a backend that wants no authentication — our own
facade, for one — is configured by leaving them out. The process prints who
it will be at startup, without the password, so an authenticated run and an
anonymous one are told apart at a glance instead of by a 401 later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
The bridge was making every HTTP request from a clean client, so the
sap-contextid cookie that selects an ADT stateful context was thrown away
between calls. Lock, write and activate are three calls that must land in
the same context; three unrelated sessions instead means the write presents
a handle whose session has gone.

Per conversation rather than per process: a jar shared between two clients
would share an ADT context between them, which is a data leak wearing the
costume of a caching bug. So the handler, its client and its jar are made
when a connection is accepted, and the backend is still checked once at
startup so a bad one fails then rather than on somebody first request.

The CSRF dance is deliberately not implemented. Eclipse does it itself — it
asks for a token and sends it back — and a bridge that joined in would be
answering a question nobody asked. What a bridge owes that exchange is not
to get in its way: keep the cookies, keep the headers, drop only what
belonged to a connection that never existed here.

Read off vsp pkg/adt/http.go, which has the full client-side dance and the
scars to go with it, and then deliberately not copied: a pipe needs the jar
and not the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
Run against Eclipse rather than the fixture: the gateway record,
F_INITIALIZE and F_ALLOCATE are all answered, five times over, and the
conversation then stops at the first F_SAP_SEND. Everything built today is
confirmed against a real client.

The reason is worth writing where the next reader will be standing. An SM59
client logs on with its own verb and the branch above answers it from a
template. Eclipse opens the conversation first and sends the logon inside an
F_SAP_SEND, so it arrives at the call branch and DecodeCutFunctionRequest
says, correctly, that it is not a call.

The answer has the same shape as everything else here. Across eleven
captured conversations that exchange is always 374 bytes in and 746 out, and
those 746 differ in nineteen bytes: the conversation id and the counter,
which this file already produces, one byte at 7, and nine more at 606..615
and 735..736 that nobody has identified yet.

So tomorrow starts at "identify nine bytes" rather than at "find out what
happens", which is the difference between a note and a dead end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…k right

Eclipse got as far as complaining about the uuid, which means the logon was
accepted and the session went on to the next thing. It said:

    Invalid uuid detected own: 2BBDE0E0-… given: 05140010-2BBD-E0E0-…

The difference analysis over eleven captured logons reported ten varying
bytes at 606, so the first version copied ten. The field is sixteen. The six
it left behind were identical in all eleven captures because all eleven came
from one machine, and those six are that machine's address packed into the
uuid's node part — so the template's copy was already correct for the only
client that had ever been measured, and wrong for every other.

Measured again across the same eleven, this time by looking for the field
rather than the offset: the request carries 0514 0010 and then sixteen bytes,
the reply carries the same sixteen at 606, and they match in eleven of
eleven. So the GUID is now found by its tag and copied whole. The tag search
is not caution for its own sake: every capture puts it at 225 and every
capture is the same client on the same host, which is not evidence that the
offset is fixed — only that nothing has varied yet.

The logon itself arrives inside F_SAP_SEND once the conversation is open, so
it is told from a call by the absence of the CUT prefix rather than by length.

The same investigation found the leak this file had been carrying. Working
out that the six bytes were an address meant reading the template as data
instead of as a blob, and the rest of it turned out to be a captured system's
host name, instance, address, logon string and user — in UTF-16LE, which is
why the scan that pronounced it clean had missed them. That scan looked for
runs of printable ASCII and every string here has a NUL after each character.
A comment in this file said "the template carries no identifiers" on the
strength of it.

All of them are replaced by same-length documentation values, so the offsets
this file counts by still count to the same place, and the two fields the
bridge overwrites anyway are zeroed instead of left plausible. The values now
describe this bridge, which is what they should have been in the first place:
answering Eclipse with another system's name was a lie as well as a leak.

Nothing here has been pushed, so none of it was public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…han guessing

A first ABAP project against the bridge failed four times, each time further
in, and each symptom named something different.

"Invalid uuid": the session GUID is sixteen bytes and ten were being copied.
The six left behind were identical across all eleven captures because all
eleven came from one machine, and those six are that machine's address packed
into the uuid's node field. Found by its 0514 0010 tag now, not its offset —
every capture puts it at 225 and every capture is the same client, which is
not evidence that the offset is fixed.

"Unexpected exception in logon operation": scrubbing the captured system's
name out of the template left a hard-coded OSD in its place, and the ABAP
project is configured with a system id it expects to hear back. Traded a leak
for a lie. The identity now comes from the bridge's own configuration.

"Response status code is not an integer value": a logon was told from a call
by the absence of the CUT prefix, and Eclipse's calls have no CUT prefix
either — so every call was answered with a logon template and Eclipse tried
to read an HTTP status out of it. A logon opens d9 c6 c3: "RFC" in EBCDIC.
The test is what a payload is, not what it is not. Its calls are also framed
without the leading chain record, so the two bytes it does not send are
supplied and the existing decoder reads the rest unchanged.

"FU_NOT_FOUND": the function name arrived as 匀䄀䐀吀开刀䔀匀吀… — Eclipse writes
UTF-16 big-endian and this read it little-endian. Not a garbled string but a
different one, which reaches the dispatcher as an ordinary lookup miss with
nothing in it to suggest an encoding.

The byte order is declared, and a review is why this says so rather than
guessing. The first fix detected it from where the zero bytes fell, which
works for ASCII names and is an inference. The opening field of every chain
carries it: across 824 captured frames Eclipse has 0x02 at index 1 in all 444
of its own and the system has 0x01 in all 380 of its, and that is exactly the
split between the text each sends. The detection stays as a fallback, applied
to names only — a parameter's value never passes through it. The codepage
field was the other candidate and the capture rules it out: tag 0x0016 comes
only from the system, always "1100", and Eclipse sends none.

Dispatcher.Invoke now returns the handler's error alongside the exception
key. SYSTEM_FAILURE says "something" to the client and, until this, said only
that to us as well.

The bridge hashes itself at startup. "Is the running binary the new one" came
up at nearly every step of this, and an unchanged symptom cannot otherwise be
told from a fix that never shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J7JpA3TGT48zBoix3Ut88s
…ct seam

A live Eclipse "Custom Application Server" logon reached the bridge and stopped
at RFC_GET_FUNCTION_INTERFACE with FU_NOT_FOUND ("CPIC-CALL: 'CMRCV'; null" on
the client). Behind it sat two more walls, each named by measuring a real
Eclipse<->A4H session through the gateway tap rather than by guessing.

Eclipse does not call a function it has not been described. A destination whose
metadata cache does not match the system it is talking to — and every bridge is
such a destination — opens with RFC_GET_FUNCTION_INTERFACE for the function's
parameters, then DDIF_FIELDINFO_GET for each structure they name, and only then
SADT_REST_RFC_ENDPOINT. So both are answered here, generated from the interface
this bridge already declares (internal/rfcserver/adt_metadata.go) and not from
any captured dictionary: the fixed-width rows a client reads are written from
field descriptors, and the descriptors say what an interface is — the parameter
names, the type of each, its width. The rows this server writes are read back
by the client-side decoders this repository already ports from a client that
reads live systems, which is the test that they are what a system sends. The
output order is the system's: a DDIF answer is DFIES_WA, then the LINES_DESCR
xRFC, then X030L_WA — the function's parameter order, not the caller's request
order — confirmed against every metadata answer in the capture.

The request itself rides in a wire family nothing here spoke: 0x4000 a flag,
0x4001 the request, 0x4002 the response, chunked, and the payload is SAP Binary
XML, not the text xRFC of the rest of the package. internal/bxml reads it and
writes it: a token stream with a name table, UTF-8 payloads, lengths written as
code points. The grammar was measured, and the measurement is the test — the
reader consumes every one of 962 captured documents to its last byte and the
writer reproduces each from what the reader made of it. The old note that the
response compressor was unknown was wrong: 0x4000's flag 01 is raw DEFLATE,
which compress/flate reads and writes directly; the metadata tables the client
sends small enough go back uncompressed as 0x0303, which is the form the system
itself uses for the PARAMS table.

A call or an answer longer than one 28000-byte record continues in F_RECEIVE
records, the last one final, the way the system's own long answers do; and a
call that arrives split is joined before it is read. Eclipse-ness is a property
of the conversation, set once its logon is accepted, because a metadata call's
chain opens with the same four bytes as a classic CUT prefix and cannot be told
from an SM59 call by its bytes alone.

Offline against the capture: 100 ADT calls decode and carry a REQUEST document,
384 more on a second capture, and all 18 metadata answers match the system's
tag sequence. The whole suite is green under -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
The bridge now decodes the ADT request, calls the backend and wraps the answer,
and a live Eclipse read that answer and refused it: "CPIC-CALL: 'CMRCV'; null",
a refusal at the CPIC receive, below the answer's content. The content was
right — measured against the system's own discovery response, the chain is the
same thirteen fields in the same order, the session GUID is the one the call
carried, the RESPONSE echo matches. What was wrong was the record the answer
travelled in.

A response leaves the gateway in a header the gateway writes, and it is not the
one the encoder built. Measured over 125 responses of a live session, that
header is a near-constant frame: gateway id 6, time 00010000, timeout 000001f4,
info4 2, sequence 1, and a 32-byte operation-info block whose first four bytes
are the message length without its 8-byte final trailer and whose last four are
00060002. The old path built an all-zero header the gateway never emits — no
sequence, no timing, an empty operation-info — and that is what CMRCV rejected;
the logon was accepted only because it is sent from a captured template that
already carries the block. The record builder now reproduces the measured
header, with a golden check against the captured bytes (uid, conversation and
length blanked — routing only), and carries it on every record of a split
answer too.

go test -race green; both oracle replays still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
…nection's

Capturing what the bridge actually sent (STG_DUMP) and diffing it against the
system settled CMRCV. The chain and its trailer were already byte-identical to
the system's (…0523 ffff 0000 ffff); only the record header differed, and the
previous fix had reproduced the wrong one.

A live session's responses wear two header shapes, and they belong to two
connection roles, not two protocols. The connection Eclipse's ADT calls arrive
on — the one that opens with 0203 — answers with what is here called format B:
operation-info 00006d60 00000002, this record's full data length, 00000001
00000000, the gateway level "4103", and a varying last word; info3 is 1; the
data ends at the chain's end, no trailer. The other connection (0204) answers
in format A: the length less eight, a 00060002 tail, and eight trailing bytes.
The previous commit built A; Eclipse aborted each answer with an F_0x0b and
reported CMRCV. Confirmed independently by a second reviewer's diff of the same
capture.

The builder now writes B, and a scratch byte-diff against the captured SADT
discovery response header found no differing byte outside uid, gateway id,
conversation and length. STG_DUMP records both directions to a file for the
next such question.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
…a session

With the transport fixed, a live Eclipse logged on and ran the whole
project-open sequence against the bridge — discovery, compatibility graph, the
25 KB discovery document, feeds, system components, system messages, the
gateway error log, runtime dumps, the object-type list at 606 KB across two
records — every one of them 200. The first POST, of
/sap/bc/adt/repository/typestructure, came back 403 with "X-CSRF-Token:
Required" and a body saying validation failed.

The handler had a comment claiming Eclipse does the CSRF dance itself, so the
bridge should stay out of the way. That is true of Eclipse over HTTPS and false
here, and the dump says so: the POST carried no token and nothing in the
exchange had ever asked for one. Over RFC there is no HTTP session between
Eclipse and anything — it hands over a request and expects the far side to be
somebody already. The bridge terminates HTTP and re-originates it, so the
session is the bridge's and so is the token.

Ported from vsp pkg/adt/http.go, which has this worked out against real
systems: probe /sap/bc/adt/core/discovery with X-CSRF-Token: fetch, HEAD first
and GET after, because some systems mint nothing on a HEAD; keep the token
beside the jar whose session it belongs to; set it on POST, PUT, DELETE and
PATCH. "Required" arrives in the same header as a token and is not one.

Narrowed on the way, twice. vsp refreshes on any 403, which suits a tool
driving its own session; here a 403 that is not a CSRF refusal is the backend's
answer to Eclipse and is passed through untouched, retried never. And a backend
that mints no token is not a failure but a backend without CSRF — our own
façade is one — so the request goes without the header rather than erroring,
which two existing tests caught before this shipped.

A token the caller put on the request was minted for a session the backend
never saw, so it is replaced rather than forwarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
…g anything

The façade this bridge exists to serve is reachable two ways in the lab, plain
on 3030 and behind TLS on 44300, and the TLS one carries a private certificate.
Pointing the bridge at it needed a way to trust that certificate.

There is deliberately no flag to skip verification. A bridge that skips it is a
bridge that can be answered by anyone on the path, and the whole point of the
thing is to carry somebody's development session. So the configuration names a
PEM instead — ca_file — and its certificates are added to the system roots
rather than replacing them, with server_name for a backend reached by an
address its certificate does not carry.

The test is the pair: the same https backend is refused when the certificate is
not named and reached when it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
Pointing a plain RFC client at the bridge — not Eclipse, the `rfc` CLI in this
repository — found a defect the Eclipse work had hidden and then a gap.

The defect. The gateway header added for Eclipse carries the communication and
connection index in the last four bytes of its operation-info, and those were a
constant. Eclipse never looks; an RFC client does, and refused every reply with
"APPC response identity does not match the active conversation". Echoing the
call's own bytes back is also wrong: a request arrives with the communication
index set to 0xffff, meaning unset, and handing 0xffff back is a reply to
nobody. A reply carries a communication index of zero — which is what the logon
accept already carried, and what the client accepts — and the connection index
the call came in on. One shape still serves both clients, so the branch by
client kind that this first grew is gone again.

The gap. Eclipse learns a structure through DDIF_FIELDINFO_GET. An RFC client
asks RFC_METADATA_GET and then RFC_GET_STRUCTURE_DEFINITION, neither of which
existed here, so it got as far as the function interface and stopped. The
latter is now answered from the same dictionary, and two things had to be right
for it to be read: RFC_FIELDS is a FLAT view, so a component of a substructure
cannot be listed beside its parent — the offsets overlap and the client says so
— and the line structures have to be types in their own right, because a client
that resolves field by field asks for them by name. A caller may also name a
table among its outputs without sending one, and is answered in the classic
framing, by name, because it gave no number to be answered by.

What this buys, measured: `rfc describe SADT_REST_RFC_ENDPOINT` through the
bridge returns the interface with both parameters typed. That is the metadata
bootstrap proven against a live client rather than against a capture — it had
never run live, because the Eclipse that tested it had the answers cached.

Where it stops: `rfc call` then fails in the client's own classic structure
codec, which does not implement a nested structure (type v). This function's
parameters are recursive and travel as BASXML, which is a different codec in
that client. The bridge is not in the way; the client needs the recursive path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
The push gate stopped this branch over a "private address" in
internal/cpic/grammar_test.go. It is the name of a test, forty-six letters and
digits long, which is exactly what the scanner's base64-run pattern looks for:
decode those characters as base64 and the bytes contain a sequence that reads
back as an address in the 172.16/12 range.

The file holds no address in any form. Every literal in it was decoded as hex
and as base64 and searched for both the dotted text and the packed prefix, and
the only hit is the identifier itself.

Allowlisted with that as the reason, which is the point of the list being
tracked and of every entry needing one. It is the sixtieth entry, and all sixty
are this same collision — a long CamelCase identifier is indistinguishable from
a base64 payload to a pattern that only counts characters. The tool's own
comment says a check that cries wolf gets read once and ignored after; sixty
says the base64 half of it has reached that point and wants a narrower rule,
which is a change to make deliberately and not inside a push.

The value itself is deliberately not written here: a commit message is scanned
too, and quoting it was what made the first attempt at this commit trip the
gate on itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: oisee <ooisee@gmail.com>
The newest and largest thing the repo does now gets the top banner: a stock
Eclipse project reaching an HTTP backend over RFC, what it took, and a link to
where the protocol facts are written down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SjjSbEc2aTNBW6rVQsn9S
Main advanced in parallel (v0.2.0, the fast serializer, a new README with a
logo and banner history, the rfc→orfc rename, a trusted-RFC type-T server).
Resolved:

- README: main's canonical version, with the bridge banner added on top as the
  newest headline.
- CHANGELOG: main's structure, the bridge noted under Unreleased.
- internal/{sniffer,ni,wire}: restored main's shims, kept alongside the public
  packages, so the lab tools that still import them build.
- serve_typet.go (main's trusted-RFC server): adapted to this branch's new
  signatures — Invoke returns the handler's cause as a third value, and
  EncodeCutFunctionResponseS4 takes the session GUID.

The bridge's own packages did not conflict. Whole tree green: build, vet, and
go test -race across 29 packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016SjjSbEc2aTNBW6rVQsn9S
@oisee
oisee merged commit 52a8a0d into main Sep 16, 2026
1 check failed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

One or more issues must be addressed before approval.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Adds an ADT-over-RFC bridge with Binary XML support, Eclipse handshake handling, backend forwarding, CSRF/session management, and public NI/wire/sniffer packages.

Changes:

  • Adds bounded wire and NI framing implementations with tests and fuzzing.
  • Implements ADT metadata, Binary XML, HTTP backend, cookies, and CSRF handling.
  • Adds Eclipse/RFC server support, bridge CLI, documentation, and integration fixtures.
File summaries
File Description
wire/wire.go Updated as part of this pull request.
wire/wire_test.go Updated as part of this pull request.
sniffer/sniffer.go Updated as part of this pull request.
sniffer/sniffer_test.go Updated as part of this pull request.
sniffer/recorder.go Updated as part of this pull request.
rfc/callback.go Updated as part of this pull request.
README.md Updated as part of this pull request.
ni/property_test.go Updated as part of this pull request.
ni/frame.go Updated as part of this pull request.
ni/frame_test.go Updated as part of this pull request.
internal/transport/transport.go Updated as part of this pull request.
internal/transport/transport_test.go Updated as part of this pull request.
internal/rfcserver/wire_constants.go Updated as part of this pull request.
internal/rfcserver/testdata/eclipse-gateway-conversation.json Updated as part of this pull request.
internal/rfcserver/serve_eclipse_test.go Updated as part of this pull request.
internal/rfcserver/serve_conscious.go Updated as part of this pull request.
internal/rfcserver/response.go Updated as part of this pull request.
internal/rfcserver/response_test.go Updated as part of this pull request.
internal/rfcserver/response_eclipse.go Updated as part of this pull request.
internal/rfcserver/request_eclipse_test.go Updated as part of this pull request.
internal/rfcserver/oracle_eclipse_test.go Updated as part of this pull request.
internal/rfcserver/eclipse_test_helpers_test.go Updated as part of this pull request.
internal/rfcserver/dispatcher.go Updated as part of this pull request.
internal/rfcserver/compact_test.go Updated as part of this pull request.
internal/rfcserver/adt_response_test.go Updated as part of this pull request.
internal/rfcserver/adt_request_test.go Updated as part of this pull request.
internal/rfcserver/adt_metadata.go Updated as part of this pull request.
internal/rfcserver/adt_metadata_test.go Updated as part of this pull request.
internal/rfcserver/adt_logon.go Updated as part of this pull request.
internal/rfcserver/adt_logon_test.go Updated as part of this pull request.
internal/rfcserver/adt_handler.go Updated as part of this pull request.
internal/rfcserver/adt_handler_test.go Updated as part of this pull request.
internal/rfcserver/adt_ddic_test.go Updated as part of this pull request.
internal/rfcserver/adt_csrf.go Updated as part of this pull request.
internal/rfcserver/adt_csrf_test.go Updated as part of this pull request.
internal/rfcserver/adt_backend.go Updated as part of this pull request.
internal/rfcserver/adt_backend_test.go Updated as part of this pull request.
internal/metadata/structure_definition.go Updated as part of this pull request.
internal/gateway/gateway.go Updated as part of this pull request.
internal/bxml/oracle_test.go Updated as part of this pull request.
internal/bxml/encode.go Updated as part of this pull request.
internal/bxml/bxml.go Updated as part of this pull request.
internal/bxml/bxml_test.go Updated as part of this pull request.
internal/appc/records.go Updated as part of this pull request.
internal/appc/fragmentation.go Updated as part of this pull request.
internal/appc/appc.go Updated as part of this pull request.
docs/protocol-primer.md Updated as part of this pull request.
docs/architecture.md Updated as part of this pull request.
conformance/ni_vectors_test.go Updated as part of this pull request.
cmd/rfc-lab/main.go Updated as part of this pull request.
cmd/adt-rfc-bridge/main.go Updated as part of this pull request.
CHANGELOG.md Updated as part of this pull request.
AGENTS.md Updated as part of this pull request.
.gitignore Updated as part of this pull request.
Review details

Suppressed comments (17)

AGENTS.md:62

  • This new command correctly points at ./ni, but the repository's CI fuzz step still invokes ./internal/ni; as a result every PR workflow fails before running these decoder fuzz checks. Update .github/workflows/ci.yml alongside this package move so the documented verification actually runs.
go vet ./...

CHANGELOG.md:14

  • This release note declares the public moves complete, but docs/provenance.md:40-44 still records the destinations as internal/ni, internal/wire, and internal/sniffer, paths that no longer exist. Update the provenance index alongside the move so the required port attribution and upstream-fix map remain usable.
- `cmd/adt-rfc-bridge`: a stock Eclipse "Custom Application Server" project
  reaches an HTTP backend over RFC. It terminates the RFC/CPIC conversation,
  unwraps the ADT HTTP request tunnelled in `SADT_REST_RFC_ENDPOINT`, forwards
  it to `--backend`, and wraps the answer. New package `internal/bxml` (SAP
  Binary XML), the dictionary bootstrap handlers, the CSRF dance, and an

cmd/adt-rfc-bridge/main.go:93

  • This startup check only calls ADTRestHandler, which validates the URL but does not call Backend.Transport. A missing or invalid ca_file is therefore discovered only after Accept when the first client connects, contrary to the adjacent “fails now” promise; validate target.Transport() before opening the listener.
	if _, err := rfcserver.ADTRestHandler(target, nil); err != nil {

internal/bxml/bxml.go:167

  • Decode accepts and materializes an arbitrarily large BXML document, while the RFC request decoder allows a compact field up to CPIC's 256 MiB default and there is no BXML-specific configurable limit. The bridge is unauthenticated on its RFC side, so a caller can submit a valid large document and force the tree plus string copies into memory; enforce a configured BXML/document bound before parsing (and keep it distinct from the protocol's maximum).
func Decode(doc []byte) (*Element, error) {
	if !bytes.HasPrefix(doc, magic) {
		return nil, fmt.Errorf("%w: no BXML magic", ErrBXML)
	}
	i := len(magic)
	var names []string
	root := &Element{Name: "#document"}
	stack := []*Element{root}

internal/rfcserver/adt_ddic.go:419

  • Both RFC_FUNINT descriptors leave Position at its zero value. The function-interface wire field is 1-based (the client-side metadata fixtures encode position 1), so the bootstrap response advertises invalid parameter geometry even though the rows are otherwise ordered correctly. Set the appropriate 1-based position when constructing each descriptor.
			{ParameterClass: "E", ParameterName: "RESPONSE", TableName: adtRestResponse, Exid: "v", InternalLength: 40, ParameterText: "ADT response"},
			{ParameterClass: "I", ParameterName: "REQUEST", TableName: adtRestRequest, Exid: "v", InternalLength: 40, ParameterText: "ADT request"},

internal/rfcserver/adt_ddic.go:554

  • For a table type, fields is replaced with the row type's fields, but the filter still compares precfield with the table type name. Every IHTTPNVP field has precfield == IHTTPNVP, so RFC_GET_STRUCTURE_DEFINITION(TIHTTPNVP) returns an empty FIELDS table despite the comment promising the line structure; use the row type as the owner for this branch so clients can resolve the table's fields.
		var top []ddicField
		for _, f := range fields {
			if f.precfield == "" || f.precfield == t.name {
				top = append(top, f)
			}

internal/rfcserver/adt_handler.go:295

  • The bridge's per-connection cookie jar is supposed to own the backend session, but this forwards an RFC-supplied Cookie header into http.Client. That lets the caller select or inject a different backend context and bypasses the jar's session isolation; drop Cookie headers from the decoded request.
		if name == "" || strings.Contains(hopByHop, strings.ToLower(name)) {
			continue

internal/rfcserver/adt_handler.go:231

  • This is substring matching rather than token matching: an end-to-end Authorization header is considered hop-by-hop because authorization occurs inside proxy-authorization. That can silently remove valid ADT request headers; use an exact case-insensitive hop-header set and reuse the predicate in every filter.
		if strings.Contains(hopByHop, strings.ToLower(name)) {

internal/rfcserver/adt_handler.go:313

  • The response paths copy Set-Cookie and X-CSRF-Token into the RFC HTTP response. Those are backend session credentials/tokens that should remain in the per-connection jar/state; exposing them lets the RFC caller steal or replay the backend session and can feed caller-owned cookies back on later requests. Filter bridge-owned state headers from the response as well.
		if strings.Contains(hopByHop, strings.ToLower(name)) {
			continue
		}
		for _, value := range values {
			headers = append(headers, map[string]any{"NAME": name, "VALUE": value})

internal/rfcserver/request.go:255

  • The maxCompactBytes bound is enforced only after inflation. An uncompressed tagCompactRequest can therefore be as large as the transport's 256 MiB NI limit and is passed to bxml.Decode, which builds its tree without the 64 MiB ADT bound. Apply the same size check to the final compact bytes before storing/decoding them, otherwise an unauthenticated caller can force excessive memory use.
    internal/rfcserver/request.go:443
  • After stripping a CUT prefix, the first chained field still names TagContextEnd as its predecessor, but this diagnostic decoder always starts with 0x0000. Consequently every valid classic request is reported as undecodable when ServeConscious logs an otherwise unrecognized call; select cpic.TagContextEnd for the CUT path and 0x0000 only for Eclipse framing.
    internal/rfcserver/request.go:245
  • An opening TagXRfcParameter sets haveXrfc, but the final validation before the function-name check never tests it. A request with a missing closing xRFC boundary is therefore accepted and the partial XML is silently discarded, so a handler may answer as though the parameter was absent; reject haveXrfc before this check.
    internal/rfcserver/response.go:144
  • Unlike EncodeCutFunctionResponse, this S/4 response path writes RowByteLength in the table header but never verifies the rows before appending them. A handler can therefore generate a response whose table geometry disagrees with its row data, causing the client to misparse the following fields; add the same per-row validation used by the other encoder.
    internal/rfcserver/response.go:110
  • The classic/S4 path has no way to receive Response.Outputs: this encoder only accepts exports, tables, and xRFC values, even though FunctionInterfaceHandler and FieldInfoHandler put requested scalar/xRFC values such as REMOTE_BASXML_SUPPORTED, DDOBJTYPE, and X030L_WA in Response.Outputs while Exports stays empty. Classic metadata clients therefore miss those outputs; pass the ordered Outputs through this API (and its caller) or convert them before encoding.
    internal/rfcserver/response_eclipse.go:115
  • The unnumbered-table branch skips the row-size validation that the numbered branch performs below. A handler can therefore emit a table header claiming RowByteLength while sending rows of another size, producing a malformed response that the client cannot decode; apply the same check before appending each unnumbered row.
    internal/rfcserver/serve_eclipse_test.go:37
  • This test is run as a normal passing assertion, but the comment still says it is expected to fail and that ServeConscious does not implement the opening sequence. That contradicts the test's purpose and the implementation under review, so future maintainers may treat a real regression as intentional; update the comment to describe the replay as an executable handshake specification.
    ni/frame.go:8
  • The port has moved these files to public ni/, wire/, and sniffer/, but docs/provenance.md:40-44,101 still points at the deleted internal/... paths. That leaves the required provenance index unable to locate the new files and prevents upstream-fix tracing; update the index as part of the package move.
  • Files reviewed: 56/57 changed files
  • Comments generated: 10
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

)

func main() {
listen := flag.String("listen", ":3300", "gateway address Eclipse connects to")
Comment thread internal/bxml/bxml.go
case n < 0x10000:
return append(out, byte(0xe0|(n>>12)), byte(0x80|((n>>6)&0x3f)), byte(0x80|(n&0x3f)))
default:
return append(out, byte(0xf0|(n>>18)), byte(0x80|((n>>12)&0x3f)), byte(0x80|((n>>6)&0x3f)), byte(0x80|(n&0x3f)))
Comment thread internal/bxml/bxml.go
Comment on lines +286 to +291
func PayloadRoot(document *Element) (*Element, error) {
abap := document.Child("abap")
if abap == nil && len(document.Children) > 0 {
abap = document.Children[0]
}
values := abap.Child("values")
Comment on lines +160 to +163
body, err := io.ReadAll(io.LimitReader(answer.Body, adtMaxBodyBytes))
if err != nil {
return Response{}, fmt.Errorf("rfcserver: the backend's answer could not be read: %w", err)
}
Comment on lines +280 to +285
ref, err := url.Parse(target)
if err != nil {
return nil, fmt.Errorf("rfcserver: REQUEST_LINE URI %q: %w", target, err)
}
body, _ := fields["MESSAGE_BODY"].([]byte)
outgoing, err := http.NewRequestWithContext(ctx, method, base.ResolveReference(ref).String(), bytes.NewReader(body))
Comment on lines +131 to +139
payload := got[80:]
if pending != nil {
pending = append(pending, payload...)
payload = pending
}
if got[30] != recordInfoFinal {
if pending == nil {
pending = append([]byte(nil), payload...)
}
// is answered in the gateway's own header; a classic client checks
// the APPC extended info instead, and the gateway block makes it
// say the reply belongs to another conversation.
records, werr := responseRecords(respCUT, convID, binary.BigEndian.Uint16(got[4:6]), got[76:80])
Comment on lines +36 to +37
// So this test is expected to fail, and the failure is the specification: it
// names the first frame where we diverge and shows both sides of it.
Comment thread ni/frame.go
Comment on lines 10 to 11
Comment thread rfc/callback.go
exports = append(exports, cpic.NamedValue{Name: name, Value: val})
}
encoded, err := rfcserver.EncodeCutFunctionResponse(exports, nil)
encoded, err := rfcserver.EncodeCutFunctionResponse(exports, nil, nil)
oisee added a commit that referenced this pull request Sep 16, 2026
adt-rfc-bridge: a stock Eclipse project reaches an HTTP backend over RFC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants