Skip to content

test(e2e): start the app, against the four nodes it claims to serve - #10

Open
emooreatx wants to merge 5 commits into
mainfrom
feat/walk-tests
Open

test(e2e): start the app, against the four nodes it claims to serve#10
emooreatx wants to merge 5 commits into
mainfrom
feat/walk-tests

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

CI compiled this client, unit-tested :shared, built a jar and a wheel, and checked facts about PyPI. It never once started the app. Every defect this repo has handed the server team lived past all of that, because it only exists once the app is running.

This adds the missing half: a walk-test harness that starts the real app against real nodes and drives it through the automation server the client already ships.

The matrix

"local and/or remote nodes carrying brains or not" is two independent axes, resolved by two different mechanisms.

corner node brain what only this corner sees
local-node the app launches it none self-launch, and the claim-PIN read the shipped wheel depends on
remote-node pre-started, CIRIS_API_URL none that the client does not start a node it wasn't asked for
remote-agent pre-started folded, answering the agent gate
remote-undetermined pre-started folded, not answering that the client does not latch

Location is not "which URL" — startServer() probes first and only launches if nothing answers, so the axis is who started the node. The remote corners assert the negative by session id rather than port liveness, because they run a real node themselves as the facade's substrate.

Brain is ClientMode. A real bare node gives NODE for free (boots in ~2s). A real agent needs an LLM bill and still can't produce undetermined on demand, because that state is a race — so BrainFacade rewrites only the routes the mode gate reads and passes everything else through untouched.

What I took, and from where

The server side was already ours (TestAutomationServer on desktop/Android/iOS — ours is ahead of CIRISAgent's, drift #19). What lived only in CIRISAgent was the driver: tools/test_desktop_wipe_setup.sh, 191 lines of curl | grep against five of the sixteen routes. Taken over and made a library.

/state is new. Inferring node-vs-agent from which widgets are on screen asserts the layout, not the gate, and passes a client that draws agent affordances against a bare node.

Two corners initially failed a blameless client

clientModeFrom demotes an answering brain to NODE when it reports itself unconfigured (CIRISAgent#1075), and undetermined requires !brainUnconfigured. Folding a brain onto a node whose /v1/setup/status still said setup_required: true wasn't presenting those states at all. The facade now presents coherent nodes. I read the contract before believing my own red.

Why the green is worth something

Mutation-tested in both directions:

  • a node binary that cannot start → local corner red (never leaves Startup, no claim PIN)
  • a brain that answers when the corner says it shouldn't → latch detector red

Not covered, not faked

local × agent. The released node binds 4242/4243 with no port override, so the facade can't sit where a self-launched node must be. That's the downstream mobile/manual test — and run_e2e prints every case it skips rather than dropping it silently.

Runtime

~4 min for the matrix. Three corners take ~5s; remote-undetermined takes ~145s because the client spends its full 60s retry budget on the probe, which is the behaviour under test.

Full detail in testing/README.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

CI compiled this client, unit-tested :shared, built a jar and a wheel, and
checked facts about PyPI. It never once started the app. Every defect this repo
has handed the server team lived past all of that, because it only exists once
the app is RUNNING: a Reset that exits instead of returning to the wizard, an
element that registers a handler but not itself, an export written where no
file manager can see it. This is the missing half.

The SERVER side of the automation surface was already ours — TestAutomationServer
on desktop, Android and iOS, and ours is ahead of CIRISAgent's (drift #19). What
lived only over there was the thing that drives it: 191 lines of `curl | grep`
against five of the sixteen routes. Taken over, and made a library.

THE MATRIX, because "local and/or remote, carrying brains or not" is two axes
and a harness that points the app at one auto-started node tests one corner:

  local-node           the app launches it        self-launch + the claim-PIN
                                                  read the shipped wheel needs
  remote-node          pre-started, CIRIS_API_URL that it does NOT start a node
                                                  it was not asked for
  remote-agent         brain folded, answering    the agent gate
  remote-undetermined  folded, NOT answering      that it does not LATCH

Location is not "which URL": startServer() probes first and only launches if
nothing answers, so the axis is who started the node — and the remote corners
assert the negative by session id, not port liveness, because they run a real
node themselves as the facade's substrate and "a node is answering" is true for
a blameless client. A real bare node gives NODE for free (boots in ~2s). A real
agent needs an LLM bill and still cannot produce `undetermined` on demand,
because that state is a race — so BrainFacade rewrites only the routes the mode
gate reads and passes everything else through untouched.

/state is new. Inferring node-vs-agent from which widgets are on screen asserts
the LAYOUT, and passes a client that draws agent affordances against a bare
node. The app publishes its own clientMode instead, and `unset` is published
rather than defaulted, because undetermined must stay visible.

THE FACADE PRESENTS COHERENT NODES, and that is load-bearing. Both the agent and
undetermined corners first failed a BLAMELESS client: clientModeFrom demotes an
answering brain to NODE when it reports itself unconfigured (CIRISAgent#1075),
and `undetermined` requires !brainUnconfigured — so folding a brain onto a node
whose /v1/setup/status still said setup_required was not presenting those states
at all. I read the contract before believing my own red.

WHAT MAKES THE GREEN WORTH ANYTHING is that it goes red on demand: a node binary
that cannot start turns the local corner red (startup never leaves Startup, no
claim PIN), and a brain that answers when the corner says it should not turns
the latch detector red. Both run and both were checked.

The scar tissue is in the code. `curl -s` on a dead server prints nothing and
exits 0, so the script this replaces read a missing app as screen "" and walked
on — every call here raises with route, status and body. The fixture refuses to
adopt a node it did not start, because a leftover on the fixed port answers
exactly like a fresh one. An explicit --node-bin that does not exist is fatal
rather than falling back to PATH: there are two different programs called
ciris-server here, and resolving to the wrong one runs the whole suite and
reports on it as though it were right. The app is launched in its own session so
its whole tree dies with the runner — a leak left an app holding 9091 that the
next run then correctly refused to touch. Each of those was found by this
harness's own runs, not reasoned about.

ONE CORNER IS NOT COVERED AND IS NOT FAKED: local x agent. The released node
binds 4242/4243 with no port override, so the facade cannot sit where a
self-launched node must be. That is the downstream mobile/manual test, and
run_e2e prints every case it skips rather than dropping it silently.

Four corners green in ~4min; the undetermined corner takes ~145s because the
client spends its full 60s retry budget on the probe, which is the behaviour
under test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a488757f3c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread testing/cases.py Outdated
Comment on lines +156 to +157
pin_file = Path(str(home)) / "claim_pin"
if not pin_file.exists() or not pin_file.read_text().strip():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exercise the client's claim-PIN reader

When PythonRuntime.readLocalClaimPin() or the setup flow stops reading the PIN, this case still passes because it opens the node-owned claim_pin file directly. That proves only that the server emitted a PIN, not that the client can consume it—the regression this case claims to protect—so the test should observe the PIN through the app or drive the claim flow far enough to require it.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

Comment on lines +250 to +252
used=$(gh release view -R CIRISAI/CIRISServer --json tagName -q .tagName)
echo "::notice::CIRISServer $want is not released yet; the walk ran against $used"
gh release download "$used" -R CIRISAI/CIRISServer -p "$(asset_for "$used")" -D node

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Record the fallback node version in the report

When the matching server tag is unavailable, this selects the latest release, but the selected used value is never passed to run_e2e.py or included in e2e-report.json. The uploaded evidence therefore does not reliably identify which compatibility pairing produced its results, despite this fallback being expected during client-first releases; add the selected tag to the machine-readable report.

Useful? React with 👍 / 👎.

Comment thread testing/node_fixture.py
Comment on lines +62 to +65
BRAIN_MERGE = {
"cognitive_state": "WORK",
"role": "agent",
"services": {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve the node role in the folded-agent facade

A real node's merged health retains role="fabric-node" while a folded brain answers, as ClientMode.kt explicitly documents, but this fixture overwrites it with role="agent". Because clientModeFrom treats the agent role as conclusive, the remote-agent corner remains green even if the client stops interpreting cognitive_state, services, or agent.reachable—the folded-node behavior this matrix is intended to exercise—so the facade should leave the real node role intact.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

Comment thread testing/node_fixture.py Outdated
Comment on lines +185 to +189
try:
wait_until_up(self.url)
except RuntimeError:
raise RuntimeError(
f"node did not come up; last log lines:\n{self.tail()}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Terminate a node that fails its startup health check

When the node process starts but remains alive without making /v1/system/health healthy, wait_until_up raises here without terminating self.proc. Since RealNode.start() never returns, Corner.start_nodes() never assigns the instance to self.node, so the corner's finally block cannot clean it up; the orphan can then occupy the fixed node port, cascade errors into every following corner, and remain on a developer's machine.

Useful? React with 👍 / 👎.

Comment thread testing/run_e2e.py Outdated
Comment on lines +256 to +259
if self.name == LOCAL_NODE:
# The app finds the node by PATH lookup; give it exactly the binary
# this run downloaded, not whatever the developer has installed.
env["PATH"] = f"{Path(self.node_bin).parent}{os.pathsep}{env.get('PATH','')}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Put the node binary on PATH for remote negative tests

In the remote corners, the downloaded ciris-server directory is not added to PATH, so a client regression that conditionally launches a local node when the executable is available cannot exhibit the behavior under test on CI. This makes did_not_launch_a_node pass for the common developer scenario where selecting a remote node still starts an installed local binary; expose the same executable to remote app processes so an erroneous launch is observable through the existing marker/session checks.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

Comment thread testing/node_fixture.py
Comment on lines +94 to +98
# Per-corner rewrites, keyed by the route they apply to.
NODE_REWRITES: dict[str, dict] = {}
AGENT_REWRITES = {
"/v1/system/health": BRAIN_MERGE,
"/v1/setup/status": CONFIGURED_SETUP,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Exercise a configured bare node

Both bare-node corners use fresh homes whose setup status reports the brain as unconfigured, and clientModeFrom treats that condition as an unconditional NODE verdict. With NODE_REWRITES empty, the suite therefore never tests classification of a configured bare node and remains green if the client incorrectly promotes every configured node to AGENT; make the remote-node facade report configured setup while retaining the bare health envelope.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

…uildable

There are research agents running fully configured and unclaimed, and no clean
route to claiming them from this client. This is the specification for closing
that, plus the client half of it. It also carries the six Codex findings on the
walk tests (PR #10).

WHAT THE INVESTIGATION FOUND, AND IT CHANGES THE DESIGN. The setup surface is
split by a loopback guard, and only ONE route is reachable off-host:

    POST /v1/setup/root          reachable remotely
    GET  /v1/setup/status        loopback only
    GET  /v1/setup/owned-nodes   loopback only
    GET  /v1/setup/consent-disclosure   loopback only

MEASURED, not read off the source: the released 0.5.190 binary, queried over
loopback and over the host's own LAN address. The reads answer 200 to
127.0.0.1 and 403 "setup routes are localhost-only" to the LAN. `/v1/setup/root`
answers 405 from the LAN rather than 403 — and that is the useful row, because a
loopback-layered route rejects before it ever considers the method. A 405
off-host is positive proof the claim route sits outside the guard.

So the two cases the operator has are not one problem:

  A  configured, unclaimed  -> buildable now; the local node signs and delivers
  B  bare, unconfigured     -> BLOCKED. The wizard reads /v1/setup/status and
                              /v1/setup/consent-disclosure, both loopback-only.
                              No client work reaches it; it needs a CIRISServer
                              decision, and the FSD states the ask.

Case A is implemented here:

FOUR OUTCOMES, NOT TWO. A wrong PIN, an already-owned node, an unreachable
target and a malformed NodeCode need different next actions, and all four
rendered as one string — "Claim failed:" plus whatever the substrate said. An
operator claiming a fleet could not tell "I mistyped eight characters" from
"this one already has an owner", which are opposite situations: one is a retry,
the other is a success that already happened. ClaimFailure classifies them.

ON THE SERVER'S CODES, NOT ITS PROSE. CIRISServer emits auth.claim.pin_invalid,
auth.claim.pin_missing and auth.claim.not_armed. The previous match was English
substrings ("claim pin", "invalid pin"), which break the moment the server
rewords — and rewording an error message is not a breaking change anybody
announces. Prose patterns are kept below the codes, for older nodes, which is
the only thing they are fit for. The verbatim message is kept ALONGSIDE the
classification and never replaced by it: UNKNOWN exists so an unrecognised
refusal reaches the operator intact instead of being flattened into a guess.

MY OWN TEST CAUGHT THE ORDERING. The not_armed sentence contains the words
"one-time PIN", so a PIN-first order reads an already-owned node as a PIN
problem and sends the operator back to a console for a PIN that was never
minted. "not armed" is now tested first, and that case is a test.

THE PRECONDITION IS ASKED BEFORE THE SECRETS. The claim is signed by the
operator's own node — the app does no crypto — so it needs that node up. That is
knowable on entry, and the screen now says so instead of collecting a NodeCode
and a one-time PIN and failing at the POST, after a wasted trip to the target's
console. Desktop, Android and iOS all ship a local node, so a signer normally
exists; web has no local runtime and cannot claim at all.

Codex on PR #10, all six:
  - the folded-agent facade no longer overwrites role. A node's merged health
    keeps role="fabric-node" while a brain answers, and clientModeFrom treats
    the agent role as CONCLUSIVE — so that one field held the agent corner green
    while the client could have stopped reading cognitive_state, the service map
    and agent.reachable entirely.
  - the bare-node corner now reports configured setup. brainUnconfigured is the
    FIRST arm of clientModeFrom, an unconditional NODE that never reaches the
    role, cognitive_state or service checks — so the corner was classifying
    correctly for a reason that bypassed classification.
  - the node binary is on PATH in EVERY corner. Keeping it out of the remote
    ones made did_not_launch_a_node vacuous on a runner, where there is no
    ciris-server to find: the regression is a client that launches a local node
    whenever it CAN, and it had no way to exhibit it.
  - a node that starts but never goes healthy is killed before the raise. It was
    left holding the fixed port, and since start() never returned the caller
    never assigned it, so nothing could clean it up.
  - which node the walk ran against is in the report. The client can cut a
    version before the server tags the matching one, and evidence that cannot
    name its pairing cannot settle a compatibility question later.
  - the claim-PIN case is REMOVED rather than fixed. It read the node's own
    claim_pin file, which proves the server emitted a PIN and says nothing about
    the client. The instinct was to instrument the client's reader; the PIN is
    not ours to instrument — the node handles it as part of the complete call,
    and the only client interest is whether this is a local first run, which the
    node's own first-run signals already answer.

AND A LIMITATION OF MY OWN HARNESS, STATED: the "remote" corners put the facade
on 127.0.0.1, so the node sees a LOOPBACK peer and the loopback-only routes
answer normally. They exercise the client's remote configuration path but not
remote reachability — a genuinely off-host client meets 403s the harness never
produces. Nothing in testing/ is evidence about off-host behaviour until a
corner makes the node observe a non-loopback source.

mobile.claim_node_no_signer is English-only here; the translate lane fills the
other 28 in CI, where the key lives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

The translate lane failed closed on Yoruba: rejected at sonnet, at opus, and at
gpt-5-pro. Escalation is the design and it ran; what it could not do is
translate a sentence that does not parse.

    Claiming binds your identity, and that signature comes from your own
    node — which is not running. Start your node, then claim.

"that signature" refers to NOTHING. No signature has been mentioned; the reader
is asked to resolve a definite reference to a noun that never appeared. That is
rule 2 of localization/TRANSLATION_GUIDE.md §3 — the dangling referent — in a
string I wrote after writing the rule.

The reviewer named both faults precisely, in Yoruba, and both are faults in the
English:

  major/accuracy  reorders the logic and makes it sound as if what is bound is
                  the node itself, not the act of claiming
  major/fluency   "kò ń ṣiṣẹ́" is ungrammatical — negative with progressive

The second looks like a translation defect and is downstream of the first: an
em-dash clause hanging off an unresolvable referent gives the model nothing to
attach the negation to.

    Your own node signs the claim, and it is not running. Start your node,
    then claim.

Subject signs object. "it" is the node, the nearest and only candidate. Nothing
is introduced that was not named.

This is the eighth time in this repo that a translation blocker was an English
blocker, and the pipeline's value is mostly that it says so out loud instead of
shipping 28 fluent renderings of a sentence nobody could follow. Nothing was
committed by the failed run: the write step ran, the commit step did not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

…pped

Second rejection, five languages this time — fr, ha, sw, uk, yo — and the same
verdict as the first: the English is what cannot be translated.

    Your own node signs the claim, and it is not running. Start your node,
    then claim.

Two faults, each of which English lets a writer leave open and no target
language can:

  "claim" FLOATS BETWEEN NOUN AND VERB, AND THE VERB HAS NO OBJECT.
    fr  needs the equivalent of "then claim IT"; the object is implicit in
        English only
    ha  bare "claim" reads as CONTINUING a claim rather than starting one
    uk  "the claim" as a noun collides with the established UI term for
        claiming a node ("заявити право на вузол") — the anchors already fixed
        a term and this string reached past it

  "IS NOT RUNNING" DOES NOT SAY WHETHER THE NODE IS STOPPED OR BROKEN.
    sw  aspect lands on "has not been operated"
    yo  reads as "it does not work / is faulty" — a broken node, not one that
        is simply not started

    Your node must be started before it can claim another node. Start your
    node, then try again.

"claim" appears once, as a transitive verb with an explicit object, so there is
no noun form to collide with the established term. "must be started" is a state
rather than a health verdict. "try again" carries the second action without a
second objectless "claim".

The reviewer is doing the job it was built for: uk did not object to grammar, it
objected that the string ignored terminology the corpus had already settled.
That is the "corpus outranks your instinct" rule in TRANSLATION_GUIDE.md §3,
enforced against me.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Machine translation, independently reviewed against MQM, and repaired
where the review found a critical, major or terminology error. Every
value here is status=draft / review_status=needs_native_review: this
pipeline guarantees terminology, structure and meaning, and does not
guarantee native fluency.

Validated by check_localization_sync.py --strict in this same run.
The MQM findings are attached to the run as i18n-report.json.
Review like any other diff.
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.

1 participant