Skip to content

Promote staging → main (WALM-617) - #916

Merged
harrymove-ctrl merged 61 commits into
mainfrom
staging
Sep 15, 2026
Merged

harrymove-ctrl merged 61 commits into
mainfrom
staging

Conversation

@harrymove-ctrl

@harrymove-ctrl harrymove-ctrl commented Sep 15, 2026 •

Copy link
Copy Markdown
Collaborator

Hop 2 of the WALM-617 promotion (week of 10 Sep 2026). Takes current staging onto main.

Heads at open

Branch SHA
staging cbbc9c62
main 01aa559b

61 commits, 98 files, +9476 / −482. This is hop 1 (#909) unchanged — staging has taken nothing else since that merge.

Gate evidence

CI on cbbc9c62: 27 success, 1 skipped, 0 failures. The skip is E2E / dev relayer, which only runs on dev.

Staging smoke was run against the published MCP artifact (@mysten-incubation/memwal-mcp@0.0.13-rc.0, what hop 1 published), not a local build, and repeated against dev for comparison:

Case dev staging
handshake 2.8s 2.1s
memwal_health 0.7s 0.2s
memwal_recall 1.1s 1.7s
memwal_remember 32.4s 24.2s
memwal_remember_bulk 50.3s — 5/5 stored 51.6s — 5/5 stored
recall agrees with bulk 5.8s 3.0s
health p50 / recall p50 153ms / 2572ms 88ms / 1828ms

/health reports write_ready=true, writes: ok, and remember returned a real blob id on both. No wallet-funding leak observed on remember.

Staging is faster than dev on every measure, so this promotion does not regress the deployment it is being promoted onto.

Write latency — measured, and accepted

A single-fact memwal_remember takes 24.2s on staging, 32.4s on dev; memwal_remember_bulk (5 facts) takes ~50s and stores 5/5. The GH field report expected "a few seconds", but that was the reporter's wording rather than an agreed target, and these figures are accepted as the current SLO for the testnet write path.

Recorded here because it is the field report's ask #1 and the numbers should be on the record, not because it blocks this hop:

  • It reproduces on dev and staging alike, and dev is the slower of the two, so nothing here introduces it.
  • The relayer accepts the job in 30ms (POST /api/remember → 202). The rest is the Walrus pipeline: registered ~10.6s (a Sui transaction) and uploaded ~10.4s (push to storage nodes) dominate. Neither is inside MCP's control.
  • What the report's §1b actually described — a bulk save that never returns and stores nothing — is gone: 5/5 stored, failed=0, on both environments.

The remaining lever at the MCP layer is the one the report itself proposes: return the job_id once the relayer answers 202 instead of polling to completion, so the agent is not blocked for the duration. That is an API behaviour change and is deliberately not part of this promotion.

Release ordering — read before merging

Merging this pushes main, which triggers release-mcp.yml to publish @mysten-incubation/memwal-mcp@0.0.13 as latest. 0.0.13 does not exist on npm yet — only 0.0.13-dev.7 and 0.0.13-rc.0:

dist-tags: { latest: 0.0.12, rc: 0.0.13-rc.0, dev: 0.0.13-dev.7 }

The plugin launch configs on this branch pin npx -y @mysten-incubation/memwal-mcp@0.0.13 (WALM-627, #913). Those become live on main the instant this merges, while the publish job still has to install, typecheck, build, test and publish. For that window a fresh plugin install resolves a version that is not on npm yet and fails. #913 called this dependency out in its own Risks section.

There are no pending changesets, so changeset version is a no-op on main and the published version will be exactly 0.0.13 — the pin resolves once the job finishes. Nothing here is permanently broken; the exposure is the length of that one workflow run. Watch Release MCP Package after merging and confirm npm view @mysten-incubation/memwal-mcp@0.0.13 version resolves before announcing the plugin release.

Package versions

staging vs main: TS SDK 0.1.7 (main 0.1.6), MCP 0.0.13 (main 0.0.12).

Supersedes

#907 (fix(relayer): reconnect Redis so account setup is not 503) was cherry-picked onto main so prod could take the relayer fix alone. Its content is already here — rate_limit.rs, Cargo.toml, Cargo.lock, walrus_seal.rs and security_delete_auth.rs are byte-identical to e088697d, and types.rs carries the same redis::aio::ConnectionManager change (main still has MultiplexedConnection). Merging this makes #907 redundant; close it afterwards rather than merging both.

Refs: https://linear.app/mysten-labs/issue/WALM-617/promote-dev-staging-main-week-of-10-sep-2026

ducnmm and others added 30 commits September 8, 2026 08:32
The style-guide audit flags "request Security review when it is required"
as passive voice and blocks merge on it. The earlier style-nit commit
changed the H1s and em dashes but missed this line.
…COMG-718)

The completion artifact is the durable operator record for a security
migration, so the target package it names has to be the one finalize-tx
actually used.

manifestSha256 was already validated, but packageId accepted any non-empty
string: "not-a-package-id-at-all" was written without complaint, and a
valid-but-unnormalized id (uppercase hex, or no 0x prefix) was recorded in a
form that no longer string-matches what build-finalize-tx.ts used, since
assertObjectId() normalizes via normalizeSuiAddress(). Mirror
isValidSuiObjectId() + normalizeSuiAddress() instead. The rules are
reimplemented rather than imported because the CI job runs this script under
bare node with no install step; verified equivalent to @mysten/sui 2.20.3
across prefix, case, length and non-hex cases.

Writing also used a plain writeFileSync, so re-running against the same --out
silently destroyed the previous ceremony's record. Open with "wx" and exit 1
on EEXIST; --force is the deliberate replace, flag-only with no env
equivalent so a stray exported variable cannot enable it.

Document both, and state plainly that the approver field is a procedure the
ceremony follows rather than a control the tooling enforces.

Self-test covers the rejected ids, the normalization, the refused overwrite
leaving the file byte-identical, and --force replacing it.
…(COMG-718)

A misspelled value flag was ignored and the field fell back to the env var of
the same name, so `--aprover user:alice` with APPROVER=ci-bot wrote ci-bot and
exited 0. An artifact that silently disagrees with the command the operator ran
is not evidence of anything, so reject any flag outside the known set.

Also reject a directory `--out` up front: "wx" reports EEXIST for one, so it hit
the overwrite branch and told the operator to pass --force, which then fails
with EISDIR.

Drop the unused third element of REQUIRED, and the docs pointer to a PR template
this repo does not have.

Self-test covers the unknown flag and the directory --out.
…n-completion-artifact

docs(ops): migration completion artifact schema (COMG-718)
…WALM-611) (#886)

* fix(chatbot): stop guest-auth redirect loop on Railway bind address (WALM-611)

* refactor(chatbot): slim public request URL helper (WALM-611)
…-719) (#845)

* fix(server): report restore decrypt failures instead of skipped (COMG-719)

Permanent decrypt/UTF-8 failures were chained into the restore skip set, so
they inflated skipped and were invisible to callers. Count them as failed,
keep skipped as the local success index only, and expose the additive field
on RestoreResponse and both SDKs.

* docs: onchain spelling and might in restore failed copy (COMG-719)

* fix(server): bound restore failed to the page and signal retry on embed-down (WALM-480)

Intersect failed with the on-chain page so it cannot exceed total. When an
inspected page yields only transients (download/decrypt/embed), set
truncated so the caller retries. Do not negative-cache embed failures.

* fix(mcp): print restore failed and retry transients instead of raising limit (WALM-480)

Show failed in memwal_restore output. When truncated is a download/embed
blip, hint retry at the same limit; keep raise-limit for WALM-431 page/cap.

* refactor(restore): slim fail-class helpers and MCP restore copy (WALM-480)

Keep classification on decrypt/UTF-8 only. Tool descriptions mention retry vs
raise-limit; the exact transient-page predicate stays in formatRestoreResult.
SKILL RestoreResult.failed matches the required TS field.

* chore: dump sdk 0.1.7 / python 0.1.10 / mcp 0.0.13 for restore failed (WALM-480)
…442) (#827)

* fix(sdk): use typed tx.pure helpers in account and manual PTBs (WALM-442)

* chore(sdk): dump 0.1.7 for typed tx.pure PTBs (WALM-442)
…d (WALM-612) (#889)

* fix(relayer): alert and write_ready when Postgres storage is exhausted (WALM-612)

* fix(relayer): alert on remember job insert and probe Neon cluster size (WALM-612)

* refactor(relayer): drop unused WALM-612 size fields and probe helpers

* fix(relayer): alert insert_vector storage exhaustion and warn when neon probe no-ops (WALM-612)

* fix(relayer): fire postgres storage alert from insert_vector (WALM-612)

* refactor(relayer): slim WALM-612 postgres storage alert paths

Keep insert_vector Slack, public.pg_cluster_size warn-fail-open,
OnceCell cap cache, alerts.rs helper, and sqlx SQLSTATE 53100.
Drop the quota-reservation duplicate, leftover remember_jobs match
rewrites, the sqlx alert wrapper, and bench insert_vector_plaintext hook.

* fix(relayer): address WALM-612 review round 2 (probe fallback, timeout warn, enqueue alert)

Fall back to sum(pg_database_size) when public.pg_cluster_size is missing,
log probe timeouts at warn with a 1s budget, restore remember_jobs INSERT
and enqueue Slack hooks, and send insert_vector alerts after the tx drops.

* refactor(relayer): slim WALM-612 round-2 extra helpers

Keep probe fallback, 1s timeout warn, remember_jobs/enqueue Slack,
and insert_vector drop(tx). Route enqueue through the sqlx 53100
helper; drop the string wrapper, wrapped-message tests, and
message-only 42883 fallback.
…895)

Closes WALM-136 (GH #322). Also settles WALM-599.

sha256hex fell back to `await import("crypto")` when WebCrypto was missing. Vite
replaces that with a stub: the app builds clean, then throws in the browser the
first time the path runs. It sat on the signed-request path, so every remember
and recall reached it, and it was the last bare Node-builtin import in the SDK.

The fallback could never have helped a browser — `crypto.subtle` is absent
precisely when the page is not a secure context, where `node:crypto` is absent
too — so it only served Node <19 while being the sole source of the exposure.
Hashes with @noble/hashes instead, already a dependency here and in @mysten/sui.

Reproduced under Vite 5.4.21 end to end: before, the build succeeds, emits a
__vite-browser-external stub, and throws TypeError: crypto.createHash is not a
function; after, no stub and a correct digest.

Adds a dist scan guarding the whole class, and the SDK's missing
engines.node >= 20.0.0, which is what left WALM-599 ambiguous.
…layer in health (WALM-390) (#876)

* fix(mcp): warn on unrecognised flags, document env presets, report relayer in health (WALM-390)

Unknown CLI flags fell through parseArgs' default branch and vanished, so a
typo'd --namesapce silently wrote to the default namespace. parseArgs now
collects what it did not match and main() names each one on stderr — warning
rather than exiting, so a flag from a newer config cannot brick the server.
An unknown flag consumes a following non-flag token as its presumed value, so
--namesapce work warns once about the flag rather than twice, the second
naming the user's data; that also keeps a mistyped secret out of the logs.

--help gained a "Network presets" section rendered from ENV_PRESETS rather
than retyped, so a new preset cannot ship undocumented the way --prod did.
Also corrects the --label default, which help gave as "Walrus Memory MCP"
against "MCP Client" in code.

memwal_health now names the relayer that answered, threaded onto
MemWalSession from the URL resolveAuth already receives. MemWal.serverUrl is
private and the server imports the published SDK, so no SDK change was needed.

* fix(mcp): report a relayer that names a network, and stop unknown flags eating commands

Review follow-ups on WALM-390.

memwal_health printed `session.relayerUrl`, which is the address the sidecar
DIALS, not a network identity. The Rust parent fills it with
`http://127.0.0.1:$PORT` whenever an operator did not override it, so on any
self-hosted or local deployment health reported a loopback address as the
network — the exact "healthy on the wrong network" failure the field was added
to catch. Hosted OAuth deployments happened to be correct only because their
issuer must already be public.

Split the two meanings apart. `relayerUrl` stays the dial address; a new
`publicRelayerUrl` carries an origin only when an operator supplied one, and
health reports nothing when it is absent. The stdio package fills the gap for
everyone else: the bridge always knows the URL it connected to — it is what
`--prod` / `--relayer` / MEMWAL_SERVER_URL selected — so it annotates the health
reply on the way through. The new sidecar test builds its session through
`resolveAuth` rather than injecting one, so the loopback case is actually
covered.

Also from review:
- An unknown flag no longer swallows `login`. `memwal-mcp --typo login` used to
  consume the command as the typo's value and never log in.
- `--tokenn=hunter2` now records `--tokenn` only. The whole token used to reach
  the stderr warning, which is the one place a mistyped secret must not land.
- Help said an explicit --relayer "overrides the preset it follows". Preset
  application is `??=`, so it wins from either side; the wording promised an
  order dependence the parser does not have.
- Dropped comments that narrated the ticket rather than a constraint.

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

* docs(reference): drop the em dash from the relayer-origin note

Style-guide audit: no em dashes in prose.

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

* docs(mcp): changelog entries for the WALM-390 fixes

The PR changed packages/mcp/src but shipped no changelog entry, so the
unrecognised-flag warning, the documented network presets and the relayer
in memwal_health would have landed in 0.0.13 undocumented.

Entries go in the existing unreleased 0.0.13 section — no version bump is
involved — and are mirrored into docs/mcp/changelog.mdx along with that
release's summary line and the frontmatter answer, as every other
packages/mcp change does.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… on-chain read (WALM-618)

Every authenticated path re-read the account object from Sui on every
request. `auth.rs::resolve_account` verified on each signed API call --
the Postgres `delegate_key_cache` only saves the registry *scan*, never
the `GetObject` -- and `mcp_proxy.rs` verified on every MCP request: the
SSE handshake and each JSON-RPC envelope alike.

One `memwal_remember` through MCP is therefore ~10 fullnode reads: SSE
open, initialize, tools/list, tools/call, `POST /api/remember`, and one
per status poll. Against the public fullnode that load throttles into
`RpcError`, which is correctly a 503 -- and a 503 makes clients reconnect
and poll again, issuing more verifies. Measured on prod: ~200 uncached
verify calls/min from reconnecting bridges, degrading every other user on
the instance.

Cache the positive result for 30s, keyed by (account object id, public
key), shared by both paths. A burst of requests carrying the same
credentials now costs one `GetObject` instead of one each.

Only successes are cached, so adding a delegate key (the tail of `login`)
takes effect immediately rather than after a TTL. A definitive rejection
also evicts the pair, so an observed revoke cannot be outlived by a
positive still inside its window; an unavailable RPC proves nothing about
the key and leaves the entry alone.

The trade this makes explicit: 30s is now the upper bound on revocation
latency at the relayer. That is the same staleness the `/agents` listing
already accepts (`DELEGATE_KEYS_CACHE_TTL`).

The map is swept on the existing 5-minute `delegate_keys_cache` task so
it stays bounded to recently-active pairs.
#809)

* fix(mcp): confirm a completed sign-in, and keep serving stdin after it

The sign-in flow reported failure twice — a notification and a notice on
the next tool call — but reported success nowhere except the log file. A
user who approved in the browser had no way to tell whether credentials
landed, whether the bridge adopted them, or whether a retry would work.

Writing a test for that confirmation surfaced a worse bug behind it. The
auth-required stub hands off to the bridge by detaching its listeners and
calling process.stdin.pause(), and a stream paused that way stays paused
however many `data` listeners attach afterwards. The bridge therefore
served only the request replayed from the hand-off and then read nothing
further, so every call after the first hung unanswered. The sign-in had
worked; the connection was deaf from the second call on.

- resume stdin in the bridge's reader, with a regression test that signs
  in mid-session and then makes two calls rather than one
- queue a one-shot banner on credential adoption, consumed by the first
  tools/call result so it states the account, delegate, and resolved
  credentials path exactly once and never repeats
- send the notifications/message twin of the existing failure warning
- move the sign-in copy into messages.ts so the signed-out stub and the
  signed-in bridge stop drifting apart, and state the resolved credentials
  path instead of assuming the global one

* fix(mcp): confirm a completed sign-in, and keep serving stdin after it

* fix(mcp): read `signedIn` from disk, and cover the re-login confirmation

Review follow-ups on WALM-394.

`handleLocalLogin` hardcoded `signedIn: true` on the assumption that the bridge
only runs while credentials exist. `memwal_logout` deletes them, and login is
intercepted before the signed-out guard, so a login after a logout in the same
session told the user they were already signed in and that a stored delegate key
would be replaced — twice wrong, and it reads as though the logout did not take.
`handleLoginToolCall` had the opposite hardcode: a second `memwal_login` after a
completed callback but before the hand-off skipped the replacement warning even
though `saveCreds` would overwrite the file.

Both now pass `signedIn: loadCreds() !== null`. The flag's contract is that
credentials already exist, which is a fact about the disk, not about which mode
answered the call.

The success tests only ever spawned signed out, so the re-login pair
(`handleLocalLogin` + `adoptCredentials`) was unexercised — dropping either its
notification or its banner queue would have passed the whole suite. The
logout-then-login test now pins the post-logout prompt, and a new test starts
already signed in and asserts both surfaces plus the one-shot.

Also from review:
- `loginFailureNotice` moved into `messages.ts` beside the success copy, as a
  pure function of the reason. The `{@link}` pointing at it did not resolve
  while it was private to `auth-required.ts`, and one module for both voices is
  what that module says it is for.
- Reattached the `runBridge` JSDoc. Inserting the `pendingLoginSuccess` docblock
  after it left two consecutive blocks, so the first documented nothing and
  `runBridge` lost its docs.

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

* docs(mcp): drop em dashes from the changelog entries

Style-guide audit: no em dashes in prose. Comma where the clause continues,
parentheses for the aside.

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

* docs(mcp): move the WALM-394 entries to the unreleased 0.0.13

The four #633 bullets sat under ## 0.0.12, which is published on npm, so
merging would have rewritten a released section. dev is unreleased 0.0.13,
which is where they belong.

Mirrored into docs/mcp/changelog.mdx, which had none of them, along with
that release's intro line and the frontmatter answer. package.json stays
at 0.0.13 — nothing here warrants a bump.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sweep carried a second, longer threshold (600s) copied from the
`/agents` cache next door. An entry past the 30s TTL can never be served
again -- `is_fresh` is the same predicate the lookup uses -- so the only
effect of the extra constant was to hold dead entries in memory for 20x
longer than they were useful. Sweep on the TTL and drop the constant.

Also states in the type's own docs why the map cannot be grown by a
caller: only successes are recorded, so every entry corresponds to a
delegate key really registered on an account. That matters because the
MCP proxy takes `x-memwal-account-id` from an unauthenticated header,
checks only that it is non-empty, and `/api/mcp/*` has no rate limit
ahead of the verify -- recording rejections would have let an anonymous
caller mint one entry per made-up account id. A test pins it.
`/api/mcp/sse` on production has served 784,627 401s against 50,958 200s.
Every one of them exits `legacy_delegate_registered` through the same arm,
whose only trace was `debug!` — dropped by the default
`memwal_server=info` filter. The route also records nothing in
`memwal_errors_total`, so 74% of the endpoint's failures (401 as a share
of all non-200 SSE responses) are invisible in both logs and metrics.

Raise that arm to `warn!` and attach `account_id`, so a rejected key can
be traced to the account that presented it. The sibling `Unavailable` arm
already logs at `warn!`; this makes the two failure modes symmetric.

Nothing else changes: the response is still 401 with no detail to the
caller, and the key itself is never logged.
The 30s verify TTL only helps a caller who repeats inside 30s. MCP
clients do not: a tool call every few minutes misses the cache every
time, so while the public fullnode throttles, a key that verified
cleanly minutes ago collects a 503 per attempt. Production shows
155,874 × 503 against 50,958 × 200 on `/api/mcp/sse`, and six
consecutive handshakes with a valid registered key all failing.

`VerifyCacheMissAction::Keep` already survived that case, but nothing
could ever read the entry it kept — the lookup filters on `is_fresh()`,
so by the time `Keep` ran the entry was unservable, and the sweeper
deleted it at the TTL besides. Keeping it was a no-op.

Give it a reader. On an unavailable error only, serve a cached success
within TTL + `DELEGATE_VERIFY_STALE_GRACE` (10 min) and log it at warn.
The sweeper now retains on the same bound so the entry still exists.

Revocation latency is unchanged at 30s whenever the chain answers. It
stretches to 10 minutes only while the chain cannot be read — a window
in which the relayer could not have observed a revoke anyway. A
definitive rejection still returns and still evicts.
Caching only successes left the larger half of the traffic uncached.
Production `/api/mcp/sse` answers 784,627 × 401 against 50,958 × 200,
and `GetObject` volume (1,147,414) tracks total requests (1,119,744)
almost 1:1 — so ~70% of the load throttling the public fullnode comes
from requests that were never going to be accepted. That throttle is
what produces the 503s everyone else sees, including callers whose
credentials are perfectly valid.

Those are not 784,627 people. A bridge holding a key the relayer will
never accept treats the 401 as a transient connect failure and retries
on a backoff capped at 15s, forever.

Remember a definitive rejection for 10s and answer from it. The client
half of the loop is #894; this half needs no client change, so it also
covers the versions already installed on users' machines.

Bounded deliberately, because unlike the positive cache this map is
keyed by what callers send rather than by what exists on chain:
- 10s, an order of magnitude under the 30s success TTL. A stale positive
  authenticates a revoked key; a stale negative only delays one that just
  became valid.
- The positive lookup runs first, so a key registered after being refused
  works on its next successful verify rather than waiting out the TTL.
- A successful verify drops any rejection for the pair.
- Hard cap of 4,096 entries. At the cap a new pair is not recorded and
  falls through to the live read — today's behaviour — rather than
  trading a throttle for unbounded memory. The sweeper expires entries on
  the same TTL so ordinary churn never reaches the cap.

An ordinary `memwal_login` is unaffected: it registers a freshly
generated key, so the pair has never been rejected and has no entry.
`openSseStream` read the relayer's `retry-after` header and then threw it
away: it was only interpolated into the Error *message string*, so nothing
machine-readable reached the retry loops. Both `connectInBackground` and
`reconnect` fell back to the generic geometric backoff, meaning the first
retry after a 429 landed ~500ms later — well inside the window the relayer
had just asked for. For `ip_active_cap`, which sends no `retry-after` at all
because it is a CONCURRENT cap that only clears when another session closes,
a sub-second retry is pure noise against a condition it cannot affect.

What changed:

- The 429 branch now throws a typed `RelayerThrottledError` carrying a
  resolved `retryAfterMs`. `Retry-After` is parsed defensively (delta-seconds
  or HTTP-date; unparseable values yield null rather than a NaN that would
  poison the backoff arithmetic), clamped to `MAX_THROTTLE_WAIT_MS` so a
  misconfigured or hostile header cannot park the bridge for hours, and
  falls back to `DEFAULT_THROTTLE_FLOOR_MS` (5s, overridable via
  `MEMWAL_MCP_THROTTLE_FLOOR_MS`) when the header is absent.

- Both retry loops floor their backoff at the recorded throttle deadline.
  The deadline is held in `throttledUntilMs` so it survives across the
  separate `reconnect()` calls that would otherwise each restart from 500ms,
  and is cleared once a session actually opens. The `immediate` reconnect
  path (a login credential swap) still bypasses everything, so re-login does
  not gain a multi-second stall.

- One `note()` per throttle episode says this is a rate limit rather than a
  bad config or bad credentials, with the next attempt's ETA. Previously the
  429 went only to a JSON stderr log line, so a throttled bridge was
  indistinguishable from a broken one.

- The non-OK handshake exits keep draining the body and still do NOT abort
  the socket. 45b0ad8 removed those aborts deliberately because that is the
  path that fired the Windows libuv assertion in this same ticket; the
  comment above them now records why, so they are not reintroduced as
  "hardening" a third time. The only behavioural change on those paths is
  `.catch()` around the drain so a truncated error body cannot mask the real
  status with a read failure.

Test: packages/mcp/test/sse-handshake-429.test.mjs spawns the bridge against
a mock relayer that 429s the SSE GET and timestamps every attempt. It pins
both the header-honoured case and the header-less floor, plus no fatal exit,
one local `initialize` reply, recovery of a call buffered during the
throttle, and the throttle-vs-misconfiguration wording. Verified failing on
the pre-fix code (attempt gaps of 504ms/1002ms) and passing after.

Changelog and the environment-variable reference are updated for the new
behaviour and for `MEMWAL_MCP_THROTTLE_FLOOR_MS`.

Not addressed here (server side, needs its own change): what the cap is
actually keyed on and the release of phantom connections. The limiter keys
solely on IP (`acquire(ip)` is its only signature), so the reporter's
inference that it tracks the account or delegate key is wrong — but a
deployment whose `TRUSTED_PROXY_HOPS` is left at 0 behind an ingress
collapses every user onto the ingress IP, which would explain a second
public IP hitting the same cap.
… (WALM-618)

A tool call that arrives before the relayer session exists waits in
`pendingForward`. The orphan sweeper did bound that wait, but it explained
every expiry the same way:

    "Walrus Memory did not answer this call. The connection to the relayer
     dropped before the result came back. Please retry."

For a buffered call that is wrong twice over. Nothing was sent, so no
connection dropped and no reply was lost; and "before the result came
back" implies a write that may or may not have landed, when in fact none
was attempted. The one thing the bridge did know -- that the MCP
handshake had been failing, and with what error -- it kept to its own
log. That is the user-visible half of WALM-618: 15 session opens for 6
calls that ever reached the relayer, and from the user's side a
`memwal_remember` that was simply slow for minutes.

Track the last handshake failure and how long the run of failures has
lasted, cleared on every successful connect. An expiry now distinguishes
the two cases and, for a call that never left this process, answers with
what it was actually stuck behind:

    "Walrus Memory could not reach the relayer - the MCP connection has
     been failing for 251s, so this call never ran and nothing was stored.
     Last handshake error: ... Check the relayer, or run `memwal-mcp login`
     if the delegate key was revoked, then retry."

Answering a buffered request also means removing it from the buffer.
Left in, the flush after the next successful connect would run the call
we just reported as never having run -- with the agent, having been told
nothing was stored, likely to have retried by then. `initialize` is
exempt, as everywhere else: it is answered locally and buffered only so
the session can still negotiate capabilities.

This stays a deadline, not a per-attempt failure. A call the next attempt
could serve is still not failed early, which is what `coldstart-timeout`
locks down and what keeps the auth-required hot-handoff request alive.

The new test drives the real shape: a relayer that 503s every handshake
the way the relayer does when the on-chain delegate verify cannot reach a
throttled fullnode, then recovers -- proving the expired call is answered
once, never reaches the relayer afterwards, and that a call sent after
recovery does.
…view)

Two review findings on the previous commit.

**The wait itself was unchanged.** Rewording the expiry left the deadline
at `callTimeoutMs` (240s by default), so a user whose handshake is
failing still sat through ~4 minutes of silence -- which, plus one
agent-level retry, is exactly the "3-8 min" the ticket reports. The
wording was better; the symptom was not fixed.

A request that is still buffered while no working connection has existed
for `stalledHandshakeMs` (90s, `MEMWAL_MCP_STALLED_HANDSHAKE_MS`) is now
answered on that shorter deadline. The two cases carry different risk,
which is the whole reason they can have different deadlines: a request
that was SENT might have executed, so failing it early invites the agent
to retry a `remember` that already landed. A request that was never sent
cannot have executed -- answering it is provably a no-op and the retry
costs one round trip.

90s is past a relayer cold start and past six reconnect attempts at the
capped 15s backoff, so it does not fire on a slow-but-recovering relayer.
The login flow is unaffected: the browser wait happens in
`runAuthRequiredServer`, before `runBridge` is called with the handed-off
lines, so no request is buffered across it.

**The failing-connection wording could describe an outage that was not
happening.** Post-connect, `handleClientLine` also buffers behind an
in-progress flush -- on a healthy session, with `handshakeFailingSince`
cleared. Such a request took the same branch and was told the connection
"has been failing for over 240s". There are three cases, not two, and the
third now says what is actually true: the call was still queued when it
timed out, and nothing was stored. That case also keeps the full
deadline, since nothing is failing.

The test now pins the short deadline specifically -- a 60s call timeout
against a 3s stalled-handshake bound, so an answer near 3s proves the new
bound fired and not the ordinary timeout.
The 429 backoff half of this branch shipped a changelog entry and an
environment-variables row; the stalled-handshake half shipped neither,
so `MEMWAL_MCP_STALLED_HANDSHAKE_MS` existed only in the source. Both
changes are user-visible, so both are now written down.
WALM-618 took two days to attribute because the relayer's own telemetry
could not show it. There is no slow request: a 3-8 minute wait is N
handshakes of 10-30ms each, separated by client-side backoff the server
never observes. Three specific gaps made it unfindable.

**Nothing tied the attempts together.** The per-request id is minted
fresh on each request, so forty retries were forty unrelated log lines.
The bridge now generates one id per connect episode and sends it as
`x-memwal-connect-id` on every attempt including retries, so one grep
returns the whole episode and the span between its first and last line
is the wait the user felt. The relayer tracks the episode and records
`memwal_mcp_time_to_session_seconds` on the attempt that succeeds — the
number no per-request histogram can produce.

**Four of the five ways to be refused logged nothing.** Only the
on-chain rejection said anything, and none of them touched a metric, so
401 — 70% of this route's traffic — was absent from logs and dashboards
alike. Every refusal now goes through one path that logs the account,
the connect id, the client, and a stable reason code, and increments
`memwal_mcp_handshake_total{outcome,reason}`.

**`/api/mcp/*` never reached `memwal_errors_total`.** `record_app_error`
fires from `AppError::into_response`, and the proxy returns raw status
tuples, so 784,627 errors incremented no error counter at all. The
refusal and unavailable paths now record.

Also sends `x-memwal-bridge-version` on the handshake. `x-memwal-client`
comes from `initialize`, which a failed handshake never reaches — and a
first connect happens before stdin is wired, so on the attempt that
matters the relayer had no idea who was calling. Which bridge build is
looping is the actionable half, and that the bridge always knows.

Bounded like the caches next to it: episodes are keyed by a value the
caller supplies, so 4,096 max and swept at 15 minutes. At the cap new
episodes simply are not timed.
**Sample the refusal line; keep the counter always-on.** `/api/mcp/*` has
no rate limit ahead of it and a stuck client retries forever, so one line
per refusal is one line per retry — 784,627 warn lines over the measured
window, from an unauthenticated header. The counter
(`memwal_mcp_handshake_total`) and `memwal_errors_total` now record every
refusal unconditionally; the line carrying account, client and reason is
written at most once per account per 60s. Bounded at 4,096 accounts, with
expiry on insert, for the same reason the caches are.

**Do not let a completed read resurrect an evicted pair.** Cold misses are
deliberately not single-flighted, so request A could be reading while B
observed a revoke and evicted — and A's older success then re-opened a
full 30s window on a key already known to be gone. The cache now carries
an eviction generation: an insert is skipped when it moved under the read.
The caller still gets its answer; it just is not cached.

**Stamp the entry from before the read, not after.** A slow `GetObject`
was extending the stated 30s revocation bound by its own duration.

**`Keep` is load-bearing in a second way the docs missed.** Beyond the
stale-grace path, it stops an unavailable RPC from evicting a *fresh*
entry another request wrote between this thread's miss and its failed
read. Documented, since the existing test only pins the stale case.

**Strategy 1's comment described pre-cache behaviour.** It still promised
a live on-chain re-verify on every request; a fresh verify-cache hit now
answers without touching Sui, including during an outage. Rewritten to say
what the code does.

**Trimmed the incident write-up out of the module comments**, per the same
note #882 got. The invariants stay on the const and the type.

Deferred: the borrowed-key lookup nit. It needs `raw_entry` or a `dyn`
key to avoid the probe allocation, which is a wider change than this PR
should carry; worth doing alongside #882's version rather than twice.
The existing test seeds a stale entry, which does not affect serving —
ducnmm's point. The case that needs pinning is the other one: a fresh
entry written by another request between this thread's miss and its own
unavailable read must survive. Keep is unconditional, so the policy is
the guarantee and no interleaving is required to assert it.
Every call built `(account_object_id.to_string(), public_key_bytes.to_vec())`
just to look up, and this now runs on the signed-request path and on every
MCP envelope — so the allocation landed on exactly the traffic the caches
exist to make cheap.

Key both maps through a `dyn DelegateAccountKey` borrow, the same shape
#882 uses for its `(String, String)` pair. A hit allocates nothing; the
owned key is built only where the map is written. The rejection cache
shares the key, so one trait covers both.

A test pins that the borrowed probe finds, and removes, what an owned
insert wrote. If the two ever hashed differently the failure would be
silent — no error, just a cache that never hits and a `GetObject` per
request, which is the condition this PR was opened to remove.
…906)

* fix(relayer): reconnect Redis and stop 503-ing sponsor without ConnectInfo (WALM-626)

* fix(relayer): bound Redis reconnect and slim WALM-626 comments
hien-p and others added 26 commits September 14, 2026 10:44
…M-386)

`parseRetryAfterMs` returned 0 rather than null for `Retry-After: 0` and
for any HTTP-date already in the past, and the caller falls back to the
floor only on nullish — so `Math.max(0, 0 ?? floor)` was 0, the throttle
wait collapsed to the ~500ms geometric backoff this feature exists to
remove, and `serverAdvised` stayed true, which also suppressed the
concurrent-cap hint. A correct server reaches the second case simply by
being a second behind the client's clock.

Both now return null, which is what the function's own docstring already
claimed it did for anything unusable.

Only reachable in production since the relayer started forwarding
`retry-after` at all, one commit ago — before that the header never
arrived and this branch was dead code.
…asing one

Two defects in `verify_delegate_key_cached`, plus a tightening of the guard
added in bef0487.

**The cap counted entries nobody could be served from.** The rejection TTL
is 10s and the sweeper runs every 300s, so the raw length that
`should_record_rejection` consults held up to thirty dead generations. A few
thousand distinct made-up pairs — `/api/mcp/*` has no rate limit and
`x-memwal-account-id` is unauthenticated — took every slot for five minutes,
during which no genuine rejection was recorded at all and every looping
client went back to one fullnode read per retry, which is the amplifier this
cache exists to remove. Entries are now expired before the cap is consulted,
the same policy `observability::should_log_refusal_at` already uses.

**A success erased the rejection that contradicted it.** The Ok path ran
`reject_cache.remove` unconditionally, before the generation check. So a
read whose result the code then declined to trust still deleted another
request's freshly recorded revoke: no positive entry (correct) and no
rejection either (wrong), sending the next N requests back to the chain. The
removal is deleted rather than made conditional — any entry present at that
point was either stamped before this read, and so already stepped past at
the lookup above and expired, or stamped during it, which is exactly the one
that must survive.

**The eviction guard now spans both maps.** bef0487 put the removal and the
generation bump under one guard; the rejection write still happened after it
dropped. Holding it across all three makes `entries` the single order between
an eviction and a concurrent store.

Deliberately NOT included, having been tried and reverted: bounding the
chain read with `tokio::time::timeout`. It converts our own deadline into
`OnchainVerifyError::RpcError`, which `is_unavailable()` reports as "the
chain could not be read" — so a fullnode answering `KeyNotFound` at 6s was
cut off at 5s and the revoked key kept serving from the stale-grace path for
up to 630s, where before it evicted. Adversarial review caught it. The
underlying issue — `verified_at` is stamped before an unbounded read, so an
entry's real life is the TTL minus that latency — is real and still open;
the fix has to let a definitive answer land rather than outrank it.
…d-session case

Both from ducnmm's review of bc531e5.

`messages_proxy` still dropped `retry-after`. The sidecar rate limits that
route as well, so a 429 on a JSON-RPC POST left the bridge unable to tell a
cap that clears on a timer from one that clears when another session closes
— the same gap the SSE and streamable allowlists just closed. It builds its
response from a fixed header array rather than the allowlist loop the other
two use, so the header is captured before `bytes()` consumes the response
and added when present.

The bridge tests only ever failed the handshake from cold start, so nothing
pinned the case the `sent` flag was actually added for: a request issued
after `firstConnectDone`, while `sse` is null, expiring on the 90s stalled
deadline instead of the full call timeout. That is the shape Dio and Teo
reported — the bridge worked, then the relayer stopped answering — and it is
exactly the one `pendingForward` membership could not see.

The new test serves one healthy session, kills it, refuses every reconnect,
then issues the call, and asserts it comes back naming the failing connection
well inside the call timeout. It needs its own mock: the existing one fails
from the first handshake, which lands the request in `pendingForward` and so
passes even with the bug present.
…unded maps

Three notes from the cloud review, all the same shape: state or logging that
this PR added, bounded everywhere except one place.

**The stale-grace warn was unsampled.** It runs on every signed request and
every MCP envelope, and it fires hardest precisely during a Sui outage, when
many requests fall past the TTL at once — the 155,874 x 503 shape measured
here. That is the same unbounded repetition the refusal line was sampled for
one commit earlier, so this PR had learned the lesson and applied it to only
one of the two. It now goes through the same per-account 60s policy.

Its own map, though, not the refusal one. The two report unrelated facts —
"this key was refused" versus "this key is being authenticated from a
verification we could not refresh" — and an account in trouble tends to
produce both, so one map would let whichever fired first silence the other
for the whole window. Pinned by a test.

**Neither sampler was swept.** Both expire on insert, but only once they hit
the cap, so an account that went quiet an hour ago held its slot until an
unrelated overflow reclaimed it. They were the only per-account maps this PR
adds that the periodic sweep did not touch; they are in it now.

**The connect-episode cap did not expire first.** `note_connect_attempt` runs
before authentication, on a route with no rate limit, keyed on a header the
caller chooses. At the cap it simply returned, so a few thousand made-up
connect ids held every slot until the 300s sweep — and while they did, no
legitimate client was timed at all, blinding `memwal_mcp_time_to_session_seconds`
during exactly the incident it exists to measure. It now expires before
consulting the cap, as the rejection cache and the samplers do.

That last one self-heals once the spam stops; it does not stop a caller who
keeps it up, and the comment says so rather than implying the hole is closed.
Bounding that properly needs an authenticated key, or the measurement moved to
the bridge, which already knows its own elapsed time.
…he call

The mid-session test raced the bridge and lost about half the time. Sending
the `tools/call` the instant the mock kills the session leaves `sse` still
set when `handleClientLine` runs, so the call takes the POST path,
`postIfCurrent` marks it `sent`, and it then correctly keeps the full call
timeout — which is the opposite of what the test exists to pin. CI caught it:
`bridge.tool_call` and `server-pump-eof` are logged in the same millisecond.

The product behaviour is right and deliberate: once a POST has been issued we
can no longer prove the call did not run, so it must keep the full deadline
even if the socket then fails. Only the test needed a synchronisation point.
It now waits for a second SSE GET to reach the mock, which means the
reconnect ran and was refused — so `sse` is null and the handshake is on
record as failing before the call is sent.
Two CI runs, two different failures, and I do not have a confirmed
explanation for the second — so this comes out rather than staying red or
landing flaky.

Run 1 was a genuine test bug: the call was sent in the same millisecond the
session died, so `sse` was still set, the call took the POST path,
`postIfCurrent` marked it `sent`, and it correctly kept the full call
timeout. Waiting for a refused reconnect before sending fixed that ordering.

Run 2 still timed out with the call unanswered, and stderr carries no
`bridge.call_orphaned` at all, so the sweeper never decided it had expired —
even though both the elapsed time and the handshake-failing time exceed the
3s deadline by the first 5s tick. That does not match my reading of the
sweeper, which means my reading is wrong somewhere I have not found. The
mock is a suspect (it answers POSTs 202 regardless of session state, where
the real relayer 404s a dead session), but that is a hypothesis, not a
diagnosis.

The product change it was meant to cover stays: `InFlightEntry.sent` is what
distinguishes "never left the process" from "might have run", and
`pendingForward` membership never could. What is missing is the proof, and
the honest state is that the mid-session path is exercised by no test.
Pinning it likely needs a seam the mock can drive rather than a timing race
against an internal state transition it cannot observe.
…bridge's own log

Third attempt, built so that a failure explains itself. The previous two
raced an internal state transition the mock cannot observe and then failed
in ways the output could not account for.

Every precondition is now confirmed from the bridge's stderr before the next
step runs — `bridge.connected` before the session is killed,
`bridge.reconnect_failed` before the call is issued — and each wait names
what it was waiting for and dumps both stderr and the received envelopes on
timeout. The mock also 404s a POST against the dead session, as the relayer
does, rather than answering 202 and letting a stray post quietly flip the
request to "sent"; the test asserts the POST count did not move, which is
the thing that actually earns the short deadline.

Covers what `pendingForward` membership structurally could not: a request
issued after `firstConnectDone`, while `sse` is null, expiring on the 90s
stalled deadline instead of the full call timeout.
The mid-session test earned its keep on the third attempt: it proved the
`sent` flag alone does not fix the case it was added for.

`sse` is not cleared when the server pump hits EOF — it keeps pointing at
the dead session until a reconnect succeeds. So a call arriving during a
mid-session outage does not take the buffering path at all: it takes the
POST path, posts to the stale session URL, and is 404'd. `postIfCurrent`
had already marked it `sent`, so it kept the full call timeout and came
back blaming a dropped connection — exactly the symptom the stalled
deadline exists to remove, and exactly what I claimed the previous commit
had fixed.

A 404 here is the relayer saying that session does not exist, so the
message was discarded rather than routed: it provably did not run. The
request therefore goes back to being never-sent and can take the short
deadline. The conservative default is unchanged everywhere else — marking
still happens before the await, because a network error mid-flight is
genuinely ambiguous and must keep the full timeout.
)

* fix(mcp): tell the client its credentials were rejected (WALM-602)

A 401 on the SSE handshake was caught by the background connect's generic
`catch`, so it backed off and retried a delegate key the relayer will never
accept. The queued `memwal_recall` stayed parked in `pendingForward` until
the orphan sweeper's deadline — 240s by default — and was then answered with
"the connection to the relayer dropped, please retry", advice that cannot
work when the key itself is the problem.

GH #365 reported the symptom as an expired session returning empty results.
The empty half closed server-side in 0.0.11 (45b0ad8 made the MCP proxy
require a registered delegate, so an unregistered key no longer opens a
session that then honestly reports zero rows). This is the client half: the
rejection now names itself and points at `memwal_login`.

Carry the 401 as `RelayerUnauthorizedError` so the connect loop can tell it
apart from a retryable failure, fail everything queued the moment it lands,
and refuse later requests immediately while the key stays rejected — the
same shape as the existing signed-out refusal, and placed just after it so
`memwal_login` still returns locally and leaves a way back in. The loop
keeps running: a successful connect clears the flag, covering both a
re-registered key and a transient WAF or rate-limit 401. Credentials are
still never wiped automatically.

Reconnect-time revocation still takes the old path; #365 is a fresh process,
so the first connect is the reported case.

* fix(mcp): keep the bridge alive and recoverable after a rejected key

Review follow-up on the WALM-602 fix. The fail-fast 401 answered the first
call correctly but left the bridge unable to come back, and the recovery the
changelog described was never reachable.

`signalFirstConnect()` on the handshake 401 let the server pump run with `sse`
still null, so it took its "stdin closed before we ever connected" break, won
the shutdown race in `runBridge`, and `markStdinClosed()` disabled the very
`reconnect()` the error text points at. `failPendingForward` writes to stdout
directly and never needed the pump, so drop the signal and leave the pump
parked until a session actually exists.

`credentialsRejected` was cleared only where the background connect publishes.
`memwal_login` republishes through `reconnect()`, so every later memory call
stayed refused. Clear it wherever a handshake is accepted, and signal
`firstConnect` there too — otherwise the first session to exist at all is one
nothing is draining until the connect backoff, up to 15s, happens to expire.

A handshake that 401s after a login already replaced the key says nothing
about the new one; latching the flag on it would refuse requests against a
live session. Guard the set with the same staleness test the publish path
uses.

Mid-session revocation now takes the same path: `reconnect()` recognises the
401, answers the replay set instead of leaving it to the orphan sweeper's
"connection dropped, please retry", and the pump keeps driving reconnects so a
transient WAF or rate-limit 401 still recovers with no client intervention.

`initialize` no longer arms a suppression it will not forward while the key is
rejected, mirroring the logout path.

Tests cover the second fail-fast call, recovery through `memwal_login` (with
the pump released promptly, not on the backoff), the in-flight call at
revocation time, and unattended recovery from a transient 401. Each fails
without its fix: 66/66, tsc clean.

* docs(mcp): list the rejected-key fix in the 0.0.13 docs changelog (WALM-602)

The package CHANGELOG carried the WALM-602 bullet, but the docs changelog's
0.0.13 section, its intro line and the `answer:` frontmatter did not mention
it. Copy the bullet and name the fix in both summaries. No version bump:
0.0.13 is still unpublished.

* fix(mcp): clear an unsent initialize's suppress arm when the key is rejected (WALM-602)

At cold start the bridge answers `initialize` locally, arms a suppression for
the upstream reply, and queues the request until a session exists. When the
handshake then 401s, `failPendingForward` closes the queue out through
`failRequest`, which keeps initialize arms on purpose for replies that can
still arrive. This one never can: the request was never forwarded.

Now that `memwal_login` restores service, the leftover arm is reachable. A
client that reuses the initialize id has the genuine reply dropped, and the
pump untracks the id as it drops it, so the orphan sweeper never answers
either. The call hangs.

Delete the arms of queued initializes before failing the queue. The new test
holds the relayer's 401 until initialize is queued, signs in, reuses id 1, and
fails by timeout without the fix.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Resolves three conflicts in `bridge.ts` and one in each changelog. All four
are both-sides-added at the same point, not competing edits, so every
resolution keeps both sides.

`#894` (WALM-602) landed on dev and touches the same connect paths:

- **reconnect() success** — it clears `credentialsRejected` and calls
  `signalFirstConnect()`; this branch resets the throttle deadline, the
  handshake-failure clock and the connect episode. Independent state, both
  kept.
- **reconnect() catch** — it answers the replay set on a
  `RelayerUnauthorizedError`; this branch records the throttle deadline and
  the handshake-failure clock, and logs with the captured reason. Kept both,
  with one log line rather than the two the raw merge would have produced.
  Noted at the site that the 401 path takes precedence over the
  stalled-handshake deadline: a rejected key is terminal until the user logs
  in again, so waiting out even the short deadline buys nothing.
- **connectInBackground() success** — same shape as the first.

The two paths cannot double-answer a request: `failRequest` deletes the id
from `inFlight` before writing, so whichever runs first removes the entry the
other would have found, and `closedOut` still absorbs a late genuine reply.

Both changelogs keep all eleven 0.0.13 bullets — dev's eight plus WALM-602's
one plus the two here.

Verified: `tsc --noEmit` clean; `cargo test --lib` 466 passed / 21 failed and
`--bins` 690 passed / 47 failed, every failure a sandbox `PermissionDenied` in
jobs/routes/walrus, matching the pre-merge baseline exactly.
…verify-cache

fix(mcp,relayer): stop refusing valid MCP credentials during a Sui throttle, and stop hiding it (WALM-618, WALM-386)
…JSON-RPC CORS (WALM-604) (#891)

Researcher's Google/Enoki sign-in failed in dev, staging and production:
public Sui fullnodes no longer answer browser JSON-RPC preflights, so
dapp-kit's ambient SuiJsonRpcClient died on getNormalizedMoveFunction with
"Failed to fetch". Noter was moved to gRPC for this in #684; researcher
was left behind.

Ports noter's approach rather than the stale researcher half of
hotfix/noter-mainnet-rpc-cors:

- lib/sui/grpc-client.ts — a standalone memoized SuiGrpcClient, bypassing
  SuiClientProvider (still hard-typed to SuiJsonRpcClient) instead of
  casting a gRPC client through it. Base URLs are hardcoded as in noter,
  so no new NEXT_PUBLIC_SUI_GRPC_URL has to be wired into three
  deploy environments to avoid silently falling back to the broken path.
- lib/sui/account-lookup.ts — registry/account reads over gRPC. These use
  include: { json: true } rather than hand-written BCS struct schemas;
  BCS schemas decode silently wrong once a struct grows a field, and
  account.move has already grown fields that noter's account-bcs.ts does
  not model.
- enoki-login-card.tsx — pre-serialize the sponsored transaction with the
  gRPC client before handing it to dapp-kit's signTransaction, which is
  what actually short-circuits the failing ABI resolution.
- sui-providers.tsx — hand registerEnokiWallets the gRPC client too;
  otherwise Enoki keeps a dead JSON-RPC client for the zkLogin flow.

Verified against live testnet and mainnet: the json reads agree with BCS
decoding on both registries, and six real owner addresses each resolve to
a MemWalAccount whose on-chain owner matches the address looked up.

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Plugin .mcp.json launched unpinned npx, so a ^0.0.5 cache never picked up
0.0.9–0.0.13. Pin args to the package version and check it in the
release verify script.
install_codex_hooks.mjs still wrote unpinned npx args when registering
[mcp_servers.memwal]. Read the package version and pin the spec.
Drop the package.json walk-up; plugin.json is already version-synced.
…od-after-write (#806)

* fix(mcp): write credentials through a fresh 0600 inode instead of chmod-after-write

saveCreds wrote the delegate private key to the final path and only then called
chmodSync(0600). writeFileSync's `mode` follows POSIX open(): the kernel applies
it when it creates the inode and ignores it for one that already exists. A
credentials.json left world-readable by anything outside this code — a manual
chmod, a restored backup, another tool — therefore received the plaintext key
under the old permission, with a second, non-atomic syscall to tighten it
afterwards. Anyone reading the path in between gets the key (GH #520).

Both writes now go through writeSecretFile(), which creates a randomly named
temp file in the target directory with { mode: 0o600, flag: "wx" } and renames
it into place. O_EXCL means the mode is always honored on creation and a temp
path that already exists — an interrupted earlier run, or a file planted by
someone else — fails hard rather than being written through. rename(2) repoints
the name atomically, so a reader sees either the whole old file or the whole new
one, never a permissive inode holding a fresh secret. The temp file is unlinked
on any failure.

The account-switch backup is fixed with it. copyFileSync + chmodSync is the same
bug class and needs no precondition at all: every switch created a second
plaintext copy of the same key under the process umask before tightening it.

The regression test asserts the property that closes the window rather than
trying to observe the race: a reader holding the pre-existing 0644 inode open
across the save must never see the new key. It fails on the previous code with
exactly that assertion.

Note the issue's line references (auth.ts:59-70, CREDS_PATH) predate the
project-local credential resolution, so its "~/.memwal is 0700 and limits
exposure" caveat is weaker than stated — credsPath() can resolve to a .memwal
inside a project directory.

* docs(mcp): address style-guide audit on the credentials changelog entry

Rewrite the account-backup sentence in active voice: the CLI is the actor
that writes the backup and that previously created it under the process
umask.

* fix(mcp): keep a Windows credential save from failing on a locked destination

Review follow-ups on WALM-312.

`renameSync` is the POSIX-correct replace, but Windows implements it as
`MoveFileEx(MOVEFILE_REPLACE_EXISTING)`, which refuses with EPERM / EACCES /
EBUSY while another handle holds the destination — an antivirus scan or a
backup agent on `credentials.json` is enough. The `writeFileSync` this PR
replaced survived that, and `login.ts` turns a thrown `saveCreds` into an HTTP
500, so the change traded a permission bug for a failed sign-in.

Windows now retries briefly and then writes in place. The fallback gives up the
atomic swap but not what this code is protecting: Windows does not enforce POSIX
mode bits at all, so `0600` was never doing the work there — NTFS ACLs are, and
they are inherited from the directory either way. POSIX keeps the plain
`renameSync` with no retry and no fallback, because there the mode IS the
protection and `rename(2)` replaces a destination regardless of who holds it.

Tests whose premise is the mode bit now skip on Windows rather than asserting
something the platform does not implement, and the backup test keeps its
content assertions everywhere. Also drops the unused `home` binding.

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

* fix(mcp): unlink the temp file when a Windows save falls back to writing in place

Review follow-up. The fallback returns successfully, so `writeSecretFile`'s
catch never runs and nothing removed the temp — which still holds the plaintext
delegate key. The comment claiming otherwise was wrong. Every locked save left
another `.credentials.json.<pid>.<uuid>.tmp` beside the credentials file, which
is the opposite of what this helper exists for, and a regression against the
old in-place `writeFileSync` that never created a sibling at all.

Unlinked best-effort after the destination write lands, so a temp that cannot
be removed does not fail a save that already succeeded.

`replaceWithTemp` now takes an injectable platform / rename / sleep and is
exported for tests. CI has no Windows runner, and this is the one branch here
that can leave a second copy of the key on disk, so it needed to be exercisable
off Windows rather than reasoned about. Seven cases: each lock code lands and
leaves nothing, repeated locked saves do not accumulate, a lock that clears
retries instead of falling back, a non-lock error still propagates, and POSIX
neither retries nor falls back.

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

* docs(mcp): fold the #520 credentials note into the open 0.0.13 release

Rebasing onto dev dropped this branch's `## Unreleased` heading and its
duplicate #705 entry, which dev had already folded into 0.0.12, but it
landed the #520 note at the end of that same 0.0.12 list. npm publishes
0.0.12 as `latest`, so a fix that is not in it cannot be listed under it.

Move the note to 0.0.13, the section dev opened and has not published
(npm carries only 0.0.13-dev.0), and say so in the mdx release line and
the `answer:` summary. The 0.0.13 bump itself came with the rebase: the
MCP manifest, the six plugin and marketplace JSON files, and
verify-manual-sdk-release.mjs already carry it, and the script passes.

* docs(mcp): match the #520 changelog bullet across both files

d860fc4 applied the style-guide audit's active-voice wording to
docs/mcp/changelog.mdx only, so the same sentence in
packages/mcp/CHANGELOG.md still read "is written" and "was previously
created". The two 0.0.13 Fixed lists are meant to stay identical.

Published sections are left as they are.

---------

Co-authored-by: Le Tien Phat <91601109+Niko1444@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hrough the public edge

Measured on dev: every MCP tool call took ~61s. `memwal_health` — one
unsigned GET the relayer serves in 10ms — answered in 61.9s, twice in a
row, warm and cold.

The relayer's own metrics locate it. Polling `/metrics` every 2s across a
single health call, the `/health` counter does not move for 60s, then
increments by exactly one, and the SSE reply lands 0.1s later. One
request, no retries: the sidecar spends a minute reaching a relayer that
then answers instantly. Neither side logs anything in between.

It is reaching it the long way round, and so is every other environment.
Read from Railway:

  prod     dials https://relayer.memory.walrus.xyz   (railway edge)  0.3s
  staging  dials https://relayer.staging.memwal.ai   (cloudflare)    0.3s
  dev      dials https://relayer.dev.memwal.ai       (cloudflare)     61s

None dials loopback, and none sets MEMWAL_PUBLIC_RELAYER_URL — because
`MEMWAL_RELAYER_URL` is both the address the sidecar dials and the only
way to make `memwal_health` name the network a session is bound to, and
the env reference told operators to set it to the public origin for
exactly that reason. So every `memwal_*` call in every environment leaves
the container and comes back to the process it started from.

Two of those round trips are cheap and one costs a minute. Why dev's is
the expensive one is not settled here: Cloudflare fronts staging too and
staging is fast, and dev's other egress is healthy in the same metrics
scrape. What is certain is that a sidecar dialling loopback never takes
that path, and that no deployment should pay a public round trip for an
in-process call as the price of naming its network.

Split the two:

- `MEMWAL_PUBLIC_RELAYER_URL` becomes an operator input of its own, so
  naming the network no longer moves the dial off loopback. An
  operator-supplied `MEMWAL_RELAYER_URL` still doubles as the public
  origin when no explicit one is set, so all three environments keep
  reporting what they report today.
- A non-loopback dial address is warned about at startup, naming the
  cost and the variable to move the value to.
- The sidecar's own default drops `localhost` for `127.0.0.1`: it only
  covers a standalone run, and there a dual-stack `localhost` can
  resolve to an address nothing answers on.

And stop it being invisible. The only reason this was findable was
diffing a Prometheus counter against a wall clock: the relayer's latency
histogram starts when the request lands, so a minute spent reaching it
reads as healthy there, and the sidecar logged `tool.call` going in and
nothing coming out. Every tool call now reports its duration —
`tool.done` at info, `tool.slow` at warn past `MCP_TOOL_SLOW_WARN_MS`
(5s), `tool.failed` on the error path — each naming the address dialled,
which is the thing an operator changes.

Tests: `cargo test -p memwal-server` 1,162 passed (6 new; 68 failures
are sandbox `PermissionDenied`, all in tests that bind local sockets).
Sidecar suite 225 passed including 3 new; its 33 failures are the same
sandbox socket restriction in `integration.test.ts` and
`instructions.test.ts`.

Follow-up to #900, which fixed the handshake half of the same user
experience. Nothing here is on that PR's path: it changed `bridge.ts`
and six Rust files, none of them the sidecar or the SDK.
…loyment's own public edge

Amends the approach in the previous commit, which was unsafe. It told
operators to unset `MEMWAL_RELAYER_URL` to stop the round trip — but that
variable is also the MCP OAuth issuer:

    // oauth.rs:161
    let issuer = std::env::var("MEMWAL_RELAYER_URL").ok()?;

The `?` makes `McpOAuthConfig::from_env` return `None`, which leaves
`config.mcp_oauth` empty and every OAuth handshake refused with
`OauthNotConfigured` (mcp_proxy.rs:397). All three deployed environments
have `MCP_OAUTH_DELEGATE_ENCRYPTION_KEY` set, so following that advice
would have taken every Claude custom connector offline.

So the dial address is the half that moves, not the public identity.
`MEMWAL_RELAYER_URL` keeps its meaning — OAuth issuer, and the network
`memwal_health` names. What the sidecar dials is now loopback unless
`MEMWAL_SIDECAR_RELAYER_URL` explicitly says otherwise, which almost
nobody should set: the managed sidecar is a child of this process and the
relayer it needs is this one.

The result is that no deployment changes an environment variable and none
pays a public round trip per tool call. Measured on dev, that round trip
costs ~61s on every `memwal_*`; prod and staging pay a cheaper version of
the same thing.

Also from review of the previous commit:

- The timing instrumentation could not observe the failure it was written
  for. It logged on settle, and a hang never settles — the 61s incident
  would have produced silence for the whole minute. It now fires from a
  timer while the call is still outstanding (`settled: false`), and the
  settle-time line marks `alreadyWarned` so one call is never counted
  twice. The timer is `unref`d so it cannot hold the sidecar open.
- `MCP_TOOL_SLOW_WARN_MS` was `parseInt`'d with no validation: "" and
  "abc" gave NaN, every NaN comparison is false, and the warning silently
  turned off. Now validated, falling back to the default and saying so.
- `tool.failed` named no error. It now carries `errName`, `errMessage`
  and `causeCode`, so the structured log is enough on its own.
- `is_loopback_relayer_url` missed IPv4-mapped IPv6 (`[::ffff:127.0.0.1]`,
  the form a dual-stack listener reports) and a trailing-dot `localhost.`.
- The dial URL is echoed in startup warnings and per-call logs, so any
  `user:password@` is stripped before it reaches either.
- The new test's 40ms threshold was tight enough to flake in CI; raised.

Tests: `cargo test -p memwal-server --bin memwal-server` 697 passed
(7 sidecar-URL tests, up from 6; the 47 failures are sandbox
`PermissionDenied` in tests that bind local sockets, none in main.rs).
Sidecar suite 75 passed across every file that does not bind a socket,
including 4 in tool-duration-log.test.ts; `tsc --noEmit` clean.
… the launcher

A `memwal_remember_bulk` whose reply never came back was reported as:

    ❌ Walrus Memory did not answer this call. The connection to the
       relayer dropped before the result came back. Please retry.

Every clause after the first is wrong for a write, and the last one costs
money. The relayer answers `/api/remember/bulk` with HTTP 202 and finishes
the work in a durable Postgres/Apalis queue, so a client-side deadline
cancels nothing: items can still be landing long after the bridge gave up.
`/api/remember/bulk` also carries no idempotency key — unlike the single
path, whose `RememberRequest` has one precisely "so an ambiguous timeout
can't produce a duplicate paid on-chain blob" — and there is no content
dedupe anywhere on the write path. So "please retry" tells the user to
mint a second paid copy of memories that may already be stored, which
`recall` will then hide behind the first.

#900 rewrote this message for the two cases where the call never left the
bridge. It copied the already-sent branch across byte for byte, and that
is the branch a bulk POSTed on a healthy session lands in: `postIfCurrent`
sets `sent = true` before awaiting the POST, deliberately, because once
the request is issued we can no longer prove it did not run.

So distinguish by what the call does, not only by whether it was sent:

- A sent `memwal_remember` / `memwal_remember_bulk` / `memwal_analyze` now
  says the write may have completed, that a timeout does not undo it, and
  to check with `memwal_recall` before re-saving anything.
- A sent read says plainly that retrying is safe, which is true and is
  what the old text meant to say.
- The two never-sent branches #900 added are untouched.

Also pin the launcher configs to `@mysten-incubation/memwal-mcp@latest`.
Unpinned, npx recorded `^0.0.5` in its cache, and a caret on a `0.0.x`
version locks the exact patch — so a reporting machine kept launching
0.0.5 for weeks after 0.0.9 through 0.0.12 shipped, and updating the
plugin did not move it. `.mcp.json`, `.cursor-mcp.json` and
`.codex-mcp.json` all carried the same unpinned spec.

Changelog entries go under 0.0.13, which is the version in package.json
and has no stable release on npm yet.
The hermetic suite proves the bridge's intent against a mock relayer. It
cannot tell you whether a deployment answers, and the 2026-09-14 report is
entirely about one that does not — so "is it fixed" kept coming down to
opinion. This runs the reported sequence against a real relayer and prints
a scorecard with thresholds taken from the report's own expectations.

Opt-in like the other live scripts: the `.live.mjs` suffix keeps it out of
the `npm test` glob and it exits early without `MEMWAL_LIVE_HOME` /
`MEMWAL_LIVE_RELAYER`. Writes cost a real Walrus blob, so T4-T6 need
`MEMWAL_LIVE_WRITE=1` and a testnet deployment; the read cases are safe to
run anywhere, production included.

T2 and T3 are the pair that matters. `memwal_health` is an unsigned GET
with no SEAL preamble, so it still answers on a deployment whose signed
path is broken — health passing while recall 504s is precisely that shape,
and it is what dev does today:

    [PASS] T1 handshake reaches a session            0.8s
    [FAIL] T2 memwal_health answers promptly        60.9s
    [FAIL] T3 memwal_recall over the signed path    60.5s  Unexpected status code: 504

T6 answers the question no log could: when a bulk reports failure, recall
is the only honest way to find out whether anything was stored — and that
answer decides whether a retry would have duplicated paid blobs.
… bit, green CI

Review fixes plus the two CI failures.

Review (@ducnmm):

- [bug] `outcomeFields()` copied `session.relayerUrl` into every `tool.slow`
  / `tool.done` / `tool.failed` line with no redaction. That field is the
  sidecar dial URL, and `redact_url_userinfo` in main.rs only covers the
  Rust startup lines — so `https://ops:hunter2@host` reached JSON stderr on
  every call. Added `redactUrlUserinfo` in the sidecar, mirroring the Rust
  helper, with two tests that exercise it through `wrapTool`. An
  unparseable value is now dropped rather than echoed: it cannot be
  redacted, so it cannot be shown. `fetch` still gets the real URL.
- `alreadyWarned` read `!watchdog.hasRef()` after `unref()`. `hasRef()` is
  false from the moment `unref()` is called, while the timer is still
  pending, so the bit was always true on settle and the comment explaining
  it was wrong. It is now set inside the timer callback.
- The notes bullet still described the revision-1 meaning of
  `MEMWAL_RELAYER_URL` ("only needed when the sidecar should call a
  different relayer"), which reads as permission to unset it — the exact
  footgun that takes `McpOAuthConfig::from_env` to `None`. Rewritten to
  point dial overrides at `MEMWAL_SIDECAR_RELAYER_URL` only.
- `MCP_TOOL_SLOW_WARN_MS` was documented as changing how a *completed* call
  is logged. The load-bearing behaviour is the in-flight warn, since a hang
  never settles. Table cell now leads with that.

CI:

- `Compile & CLI Smoke` — my own test was flaky. It asserted
  `durationMs >= threshold` on the FIRST `tool.slow` line, which is now the
  in-flight one emitted by the timer at the threshold itself; its duration
  sits within a millisecond of the threshold and landed at 149 against 150
  on the runner. It now asserts on the settled line, which is the one whose
  duration must exceed the threshold.
- `MCP / Integration (login handoff)` — `orphaned-call.test.mjs` asserted
  that an orphaned `memwal_remember` tells the caller it is "safe to
  retry". That is the assumption this PR reverses on purpose: the call was
  POSTed, the relayer answered 202 and finished in a durable queue, and
  `/api/remember/bulk` has no idempotency key, so a blind repeat buys a
  second paid blob. The test now asserts the write-case contract — says it
  may have completed, points at `memwal_recall`, never says "please retry"
  — and is renamed to match. Verified each assertion against the emitted
  string; the negative is scoped to "please retry" because the message
  deliberately says it "does not mean nothing was stored".

Sidecar suite 77 passed (6 in tool-duration-log, 2 new), `tsc --noEmit`
clean. The `packages/mcp` suite still cannot run in this sandbox (`listen`
denied), so `orphaned-call.test.mjs` is verified by CI rather than locally.
Two items from the re-review of 27e48aa that the previous commit did not
cover. The rest of that review ("still unfixed": wrapTool userinfo, the
`hasRef` pairing bit, the stale notes bullet, the two CI failures) landed
in 195e9b6, which was pushed 36 seconds after the review was submitted.

- `docs/mcp/changelog.mdx` carried none of the new 0.0.13 entry. The
  Mintlify page is the user-facing changelog, and its `answer:` block is
  what AI search cites, so a fix that only exists in the package
  CHANGELOG is invisible where people actually look. Copied the bullet
  into `### Fixed`, and led both the intro and the `answer:` with it —
  "a lost reply does not mean the write did not land" is the sentence a
  user needs before they retry something that costs money. No version
  bump: dev is already 0.0.13 and it is unpublished.

- Dropped the launcher JSON from this PR. `@latest` is a moving dist-tag,
  not a pin, and #913 (WALM-627) already does this properly: it pins the
  exact `@0.0.13` that `packages/mcp/package.json` declares, and covers
  the Codex fallback installer too, which this PR did not touch. Two PRs
  editing the same three files with different values is a conflict for no
  benefit. Removed the matching CHANGELOG bullet with it.

That leaves this PR to one subject again: the sidecar dialling loopback,
the in-flight slow-call signal, and the sent-write message.
fix(mcp): make dev usable again — dial loopback instead of the deployment own public edge
…-npx-so-users-stay-on-stale-memwal

Conflicts came from #912 touching the same launch configs and the same
0.0.13 changelog section.

- `.cursor-mcp.json` / `.codex-mcp.json`: keep this branch's exact
  `@0.0.13` pin over dev's `@latest`. `@latest` still lets npx serve a
  cached build, which is the failure WALM-627 is about, and dev's
  4f2bad7 left the launcher pin to this PR on purpose.
- `packages/mcp/CHANGELOG.md` and `docs/mcp/changelog.mdx`: both sides
  added an entry under the same unpublished 0.0.13 heading, so keep
  both, and fold the pin clause back into dev's rewritten `answer:`
  summary and release blurb rather than dropping either side's prose.

`node scripts/verify-manual-sdk-release.mjs` passes, including the new
launch-config drift check.
…aunches-unpinned-npx-so-users-stay-on-stale-memwal

fix(mcp): pin memwal-mcp version in plugin npx (WALM-627)
Promote dev → staging (WALM-617)
@harrymove-ctrl
harrymove-ctrl merged commit 0ffbacb into main Sep 15, 2026
58 checks passed

This branch was successfully deployed

1 active and 1 inactive deployments
Walrus Memory / staging — cbbc9c62 Deployed Sep 15, 2026 by railway-app[bot]
benchmark-staging — cbbc9c62 Deployed Sep 15, 2026 by harrymove-ctrl via Memory API Latency #339
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.

4 participants