From 900149ce270031d6dc32e2471f3765fe69029a66 Mon Sep 17 00:00:00 2001 From: Jessie Mongeon <133128541+jessiemongeon1@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:37:56 -0500 Subject: [PATCH 1/9] Update README.md --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 3149a573..4f07b2f8 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,7 @@ data they can trust. ## For AI Agents - **Single-file guide**: Read [`SKILL.md`](SKILL.md) for a complete integration reference (install, configure, API surface, troubleshooting) -- **LLM-friendly docs**: [`llms.txt`](https://docs.wal.app/walrus-memory/llms.txt) — structured overview following the [llmstxt.org](https://llmstxt.org) standard -- **Full context**: [`llms-full.txt`](https://docs.wal.app/walrus-memory/llms-full.txt) — expanded version with inlined page content +- **LLM-friendly docs**: [`llms.txt`](https://docs.wal.app/llms.txt) — structured overview following the [llmstxt.org](https://llmstxt.org) standard ## Install From ddaf3abdc44ff11e2f79994b3e14004dc0726c24 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Thu, 4 Jun 2026 23:13:20 +0700 Subject: [PATCH 2/9] Simplify Walrus Memory setup skill --- apps/app/public/skills/setup | 312 +++++++++++++++-------------------- 1 file changed, 129 insertions(+), 183 deletions(-) diff --git a/apps/app/public/skills/setup b/apps/app/public/skills/setup index 69d8d56a..9ea9d19d 100644 --- a/apps/app/public/skills/setup +++ b/apps/app/public/skills/setup @@ -1,146 +1,100 @@ ---- -name: setup -description: >- - Set up Walrus Memory for normal users in AI clients such as Claude Desktop, - Cursor, Codex, Claude Code, ChatGPT, Antigravity, or another agent interface. - Covers local MCP clients, remote MCP clients, wallet sign-in, restart, and - verification. -license: Apache-2.0 -metadata: - author: Walrus Memory - version: "1.0" - requires: Node.js 20+ for local stdio clients; a Sui-compatible wallet ---- - -# Walrus Memory - One-Prompt Setup - -## Purpose - -Set up Walrus Memory for a normal user inside their AI client. This flow is -for Claude Desktop, Cursor, Codex, Claude Code, ChatGPT, Antigravity, and other -agent interfaces that can use MCP or remote MCP-style tools. - -When invoked through a one-click install prompt: +# Walrus Memory MCP Setup -```text -Run `curl -sL https://memory.walrus.xyz/skills/setup` and use the -returned instructions to connect Walrus Memory to this AI client. -``` - -Fetch this file, choose the right setup mode for the user's AI client, configure -Walrus Memory, sign in with their Sui wallet, restart the client when needed, -and verify memory works. - -## Scope - -Do this: +This page is meant to be read by an AI client. -- connect Walrus Memory MCP to the user's AI client -- help the user sign in through `memwal_login` or the MCP package login command -- verify `memwal_remember` and `memwal_recall` +If you are reading it yourself, paste this into your AI client instead: -Do not do this in this flow: - -- configure MCP only because a code project exists -- install the Walrus Memory SDK or edit app source code +```text +Run `curl -sL https://memory.walrus.xyz/skills/setup` and use the returned +instructions to connect Walrus Memory to this AI client. +``` -If the user explicitly asks for SDK or app-code integration, stop and say this -one-prompt setup is for using Walrus Memory inside an AI client. Point them to -the SDK docs or ask whether they want a separate developer integration. +The AI client should either configure Walrus Memory automatically, or give you +the exact config and terminal commands to run. -## Rules +## Agent Goal -1. Treat MCP client setup as the primary goal. -2. Ask which AI client to configure if the user did not name one. -3. Detect whether this AI client can execute local shell commands and edit - local user config files on the user's machine. -4. If this AI client cannot access the user's local terminal or filesystem - (common in Claude Desktop and other desktop chats), do not pretend setup is - automatic. Give the exact command/config for the user to run or paste - manually, then wait for them to report the result. -5. If local shell/file access is available, show the command or config file - before changing user-level MCP config. -6. Merge config into existing `mcpServers`; do not remove other MCP servers. -7. Never commit or print real delegate private keys from - `~/.memwal/credentials.json`. -8. Prefer local stdio MCP for clients that support `command + args`. -9. Use remote Streamable HTTP only when the client clearly supports remote MCP - servers with custom headers. -10. ChatGPT custom MCP apps use a remote MCP flow. Do not invent a local `npx` - ChatGPT Desktop config. -11. After config changes, tell the user to fully quit and reopen the AI client. -12. Stop at the first failure and report the exact blocker. +Connect Walrus Memory MCP to the user's AI client, sign the user in, and verify +that memory tools work. -## Execution modes +Do not install the Walrus Memory SDK or edit application source code unless the +user explicitly asks for developer integration. -### Guided mode +## Setup Rules -Use guided mode when the AI client cannot directly run terminal commands or -write local files on the user's machine. This is common for Claude Desktop, -ChatGPT, and other desktop/web chat clients. +1. First decide whether you have local shell and filesystem access. +2. If you can edit local user config files, do the setup yourself after showing + the config you will add. +3. If you cannot edit local files, give the user exact commands/config to paste + and wait for their result. +4. Preserve existing MCP servers. Merge the `memwal` server; do not replace the + whole config. +5. Never print or expose values from `~/.memwal/credentials.json`. +6. Tell the user to fully quit and reopen the AI client after config changes. +7. Stop at the first real blocker and report the exact failure. +8. Keep the final status short. Do not repeat full config blocks or terminal + commands after setup succeeds unless the user must copy/paste them manually. -In guided mode: +## Requirements -- tell the user which config file to open -- show the exact JSON/TOML block to paste -- show the exact terminal command to run, if needed -- ask the user to confirm what happened before continuing -- do not say that you installed or configured anything yourself +For local MCP clients, check Node.js: -### Agentic mode +```bash +node -v +``` -Use agentic mode only when this session has explicit local shell and filesystem -access on the user's machine. +Walrus Memory MCP requires Node.js 20 or newer. If Node is missing or older +than 20, ask the user to install Node.js 20+ from https://nodejs.org/ and stop. -In agentic mode: +The user signs in through the browser with Google/zkLogin or a Sui-compatible +wallet. The sign-in flow writes persistent credentials to: -- inspect existing config before editing it -- preserve other MCP servers -- show a short summary of the edit -- still ask the user to restart the AI client manually +```text +~/.memwal/credentials.json +``` -## Step 1 - Check Node.js +Those credentials are what survive MCP server restarts. -First decide whether you are in guided mode or agentic mode. +## Recommended Login -For local stdio clients, Node.js must be checked on the user's machine. In -guided mode, ask the user to run: +When you can run a real terminal command, prefer this first. Run it from the +user's home directory when possible, especially if the current project is a +Node monorepo: ```bash -node -v +npx -y @mysten-incubation/memwal-mcp login --prod ``` -In agentic mode, run it yourself. Walrus Memory MCP requires Node.js 20 or -newer. If Node.js is missing or older than 20, tell the user to install Node.js -20+ from https://nodejs.org/ and stop. - -The user also needs a Sui-compatible wallet available in the browser for login. - -## Step 2 - Pick the client +This opens the browser login flow and saves `~/.memwal/credentials.json`. -If the user did not name a client, ask: +If terminal login is not possible yet, configure the MCP server first, restart +the AI client, then ask the client to run: ```text -Which AI client should I connect to Walrus Memory: Claude Desktop, Cursor, -Codex, Claude Code, ChatGPT, or another MCP client? +Use memwal_login to sign me in to Walrus Memory. ``` -Use local stdio config for: +If `memwal_login` gives a link, show the link to the user and tell them to sign +in with Google/zkLogin or a Sui-compatible wallet. After the browser shows +Connected, retry the original memory action. + +Do not call `memwal_login` again just because a later login URL uses a new +localhost port. A new callback port is normal for each login attempt. What +matters is whether `~/.memwal/credentials.json` was written. -- Claude Desktop -- Cursor -- Codex -- Claude Code -- Antigravity or another local-command MCP host +If the user says the browser shows Connected but memory tools still act +unsigned-in, use the terminal login fallback: -Use remote MCP guidance for: +```bash +npx -y @mysten-incubation/memwal-mcp login --prod +``` -- ChatGPT custom MCP apps -- clients that do not support local `command + args` +Then retry the memory tool. If the client still does not see credentials, fully +quit and reopen the AI client. -## Step 3 - Configure local stdio MCP +## Local MCP Server -Use this MCP server command: +Use this stdio MCP command for local MCP clients: ```bash npx -y @mysten-incubation/memwal-mcp @@ -183,12 +137,22 @@ Merge: ### Codex -Edit `~/.codex/config.toml` and append: +Edit `~/.codex/config.toml` and merge: + +```toml +[mcp_servers.memwal] +command = "npx" +args = ["-y", "@mysten-incubation/memwal-mcp"] +``` + +If Codex starts MCP servers from a project directory and `npx` resolves the +wrong package, set the server working directory to the user's home directory: ```toml [mcp_servers.memwal] command = "npx" args = ["-y", "@mysten-incubation/memwal-mcp"] +cwd = "/Users/YOUR_USERNAME" ``` ### Claude Code @@ -205,42 +169,21 @@ Verify: claude mcp list ``` -### Other local MCP clients +### Other Local MCP Clients -Add: +Add an MCP server with: - name: `memwal` - command: `npx` - args: `["-y", "@mysten-incubation/memwal-mcp"]` -## Step 4 - Sign in +## Restart -Prefer inline login after the client is configured and restarted: +Fully quit and reopen the AI client after editing MCP config. On macOS, use +`Cmd+Q`; closing the window is not enough. The first launch may take 5-10 +seconds while `npx` fetches the package. -```text -Use memwal_login to sign me in to Walrus Memory. -``` - -The tool opens a browser flow. The user connects their Sui wallet and approves -the delegate key. Credentials are saved locally at: - -```text -~/.memwal/credentials.json -``` - -If inline login is not available yet, run manual login in a real terminal: - -```bash -npx -y @mysten-incubation/memwal-mcp login --prod -``` - -## Step 5 - Restart the client - -Fully quit and reopen the AI client. On macOS, use `Cmd+Q`; closing the window -is not enough. The first start may take 5-10 seconds while `npx` fetches the -package. - -## Step 6 - Verify tools +## Verify Tools Ask the AI client: @@ -257,55 +200,35 @@ Expected Walrus Memory tools: - `memwal_login` - `memwal_logout` -If only `memwal_login` works, credentials are missing. Run `memwal_login` or -the manual login command again. - -## Step 7 - Verify memory - -Ask: +If only `memwal_login` works, credentials are missing or stale. Run +`memwal_login`, or run the terminal login command again: -```text -Use memwal_remember to save: "My favorite programming language is Rust and I drink black coffee in the mornings." -``` - -Wait a few seconds, then ask: - -```text -Use memwal_recall to search for: "what is my favorite language?" +```bash +npx -y @mysten-incubation/memwal-mcp login --prod ``` -The client should retrieve the saved Rust memory. - -## ChatGPT - -ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. +## Verify Memory -If the user's ChatGPT account supports custom MCP apps / developer mode: - -1. Confirm they can create a custom MCP app in ChatGPT settings. -2. Use this remote MCP endpoint: +Use a neutral setup check rather than saving a fake personal preference: ```text -https://relayer.memory.walrus.xyz/api/mcp +Use memwal_remember to save: "Walrus Memory setup verification succeeded." ``` -3. Authenticate with headers: +Wait a few seconds, then ask: ```text -Authorization: Bearer -x-memwal-account-id: +Use memwal_recall to search for: "setup verification succeeded" ``` -The values come from `~/.memwal/credentials.json` after a successful -`memwal-mcp login --prod`. +If the tools support a `namespace` argument, use a setup-only namespace such as +`setup-verification`. -If ChatGPT cannot attach the required headers or the user's plan/workspace does -not allow custom MCP apps, explain the blocker and offer Claude Desktop, -Cursor, Codex, or Claude Code as the local MCP path. +## ChatGPT and Remote MCP -## Remote MCP / Streamable HTTP +ChatGPT custom MCP apps use remote MCP servers, not a local `npx` stdio config. -Use this only when a client supports remote MCP servers with custom headers: +Use remote MCP only when the client supports custom headers: ```json { @@ -321,23 +244,46 @@ Use this only when a client supports remote MCP servers with custom headers: } ``` -Treat the bearer token like an API key. Do not save it in a repo file. +The values come from `~/.memwal/credentials.json` after login. Treat the bearer +token like an API key. Do not save it in a repo file or print it in chat. + +If the client cannot attach required headers, explain the blocker and suggest a +local MCP client such as Cursor, Claude Desktop, Claude Code, or Codex. ## Troubleshooting | Symptom | Fix | | --- | --- | | `node: command not found` | Install Node.js 20+ from https://nodejs.org/. | -| `npx` fails | Confirm Node/npm is installed and internet access works. | +| `npx` fails | Confirm Node/npm and internet access. If inside a Node monorepo, run from the home directory or set MCP `cwd` to the home directory. | | No Walrus Memory tools after restart | Check the MCP config path and fully restart the client. | | Only `memwal_login` works | Credentials are missing. Run `memwal_login` or `npx -y @mysten-incubation/memwal-mcp login --prod`. | | Memory tools return 401 | The delegate key may be stale or revoked. Run `npx -y @mysten-incubation/memwal-mcp login --prod` again. | -| User wants to sign out | Run `npx -y @mysten-incubation/memwal-mcp --logout`. This removes local credentials but does not revoke the on-chain delegate key. | +| Sign out | Run `npx -y @mysten-incubation/memwal-mcp --logout`. This removes local credentials but does not revoke the delegate key. | + +## Final Report + +Keep the final response short and make the restart the first thing the user +sees whenever a restart is required. + +If setup changed local MCP config successfully, use this shape: + +```text +Walrus Memory is configured. + +Next: fully quit and reopen now. On macOS, use Cmd+Q; closing the +window is not enough. + +Login: succeeded. +Config: . +After reopening, ask: "What MCP tools do you have available?" +``` -Finish by reporting: +Rules for the final response: -- AI client configured -- config file changed or command run -- whether login succeeded -- whether `memwal_remember` / `memwal_recall` passed -- any restart or follow-up the user still needs +- Put the restart instruction before verification details. +- Keep it under 8 lines if there is no blocker. +- Do not include the full TOML/JSON config again after it has already been + applied. +- Do not include long verification prompts before the restart. After restart, + the simplest check is: `What MCP tools do you have available?` From 4cb54744234a23c3ed982a40d70c01a092883788 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 5 Jun 2026 02:13:30 +0700 Subject: [PATCH 3/9] fix(mcp): accept loopback callback (PNA) + hot-handoff to bridge after login Two fixes to the stdio MCP login flow, both surfaced while connecting MCP clients to Walrus Memory. - login.ts: the localhost callback listener now echoes Access-Control-Allow-Private-Network: true on the CORS preflight when the browser asks for it. Chrome Private Network Access blocks a fetch from an HTTPS public page (memory.walrus.xyz) to 127.0.0.1 unless the listener grants it, so on-chain registration succeeded but credentials never reached the CLI (the 'did not accept the callback' error). - auth-required.ts / bridge.ts / index.ts: when memwal_login writes credentials mid-session, the auth-required server now detects them on the next tool call and hands off to the bridge IN THE SAME PROCESS, replaying the request that triggered the handoff. Removes the second client restart (the 'double reboot'). - bump package version to 0.0.4 for publish. --- packages/mcp/package.json | 2 +- packages/mcp/src/auth-required.ts | 286 +++++++++++++++++++----------- packages/mcp/src/bridge.ts | 26 ++- packages/mcp/src/index.ts | 17 +- packages/mcp/src/login.ts | 12 ++ 5 files changed, 237 insertions(+), 106 deletions(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 8a8b8829..e48c0cb7 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@mysten-incubation/memwal-mcp", - "version": "0.0.2", + "version": "0.0.4", "description": "Walrus Memory MCP client — single-binary stdio MCP server that bridges Cursor / Claude Desktop / Antigravity / Claude Code to the Walrus Memory relayer. Handles browser-based wallet login on first run.", "type": "module", "engines": { diff --git a/packages/mcp/src/auth-required.ts b/packages/mcp/src/auth-required.ts index e1d935bc..64bdbaca 100644 --- a/packages/mcp/src/auth-required.ts +++ b/packages/mcp/src/auth-required.ts @@ -21,6 +21,7 @@ * MCP spec 2025-06 — see ENG-1750. The two paths cover different surfaces * and coexist. */ +import { loadCreds, type MemWalCredentials } from "./auth.js"; import { log } from "./logger.js"; import { loginFlow } from "./login.js"; @@ -133,24 +134,6 @@ function writeStdoutMessage(msg: RpcMessage): void { process.stdout.write(JSON.stringify(msg) + "\n"); } -function readStdinLines(onLine: (line: string) => void): Promise { - return new Promise((resolve) => { - let buf = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (chunk: string) => { - buf += chunk; - let nl: number; - while ((nl = buf.indexOf("\n")) >= 0) { - const line = buf.slice(0, nl).replace(/\r$/, ""); - buf = buf.slice(nl + 1); - if (line.length > 0) onLine(line); - } - }); - process.stdin.on("end", () => resolve()); - process.stdin.on("close", () => resolve()); - }); -} - /** Config passed in by the entry point (`index.ts`) so the login flow uses * the same web/relayer URLs as the rest of the CLI (e.g. `--dev` → * dashboard at `https://dev.memwal.ai`, not the prod default at @@ -302,107 +285,208 @@ async function handleLoginToolCall( }; } +/** Returned by {@link runAuthRequiredServer} when the user signs in mid-session + * (via `memwal_login`) and the request should now be served by the real bridge + * instead — WITHOUT a client restart. */ +export interface AuthHandoff { + creds: MemWalCredentials; + /** Lines the auth-required reader already pulled off stdin that the bridge + * must process first: the tool call that triggered the handoff, plus + * anything buffered behind it. */ + pendingLines: string[]; +} + +/** + * Dispatch one JSON-RPC line in auth-required mode. + * + * Returns the freshly-loaded credentials when `memwal_login` has written them + * since spawn and the request should be handed to the bridge for real + * servicing. Returns null when the line was fully handled locally (initialize, + * stub tools/list, login tool call, or the not-signed-in nudge). + */ +function handleAuthLine( + line: string, + config: AuthRequiredConfig, +): { creds: MemWalCredentials } | null { + let req: RpcMessage; + try { + req = JSON.parse(line) as RpcMessage; + } catch { + return null; + } + + // Notifications don't need a response. + if (req.id == null && typeof req.method === "string") { + return null; + } + + const id = req.id ?? null; + const method = req.method; + + if (method === "initialize") { + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: "2024-11-05", + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: "memwal", version: "0.0.1" }, + }, + }); + return null; + } + + if (method === "tools/list") { + // Signed in since spawn? Hand off so the client gets the real upstream + // tool list (with memwal_login/memwal_logout spliced in) from the bridge. + const creds = loadCreds(); + if (creds) return { creds }; + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { tools: TOOL_DEFINITIONS }, + }); + return null; + } + + if (method === "tools/call") { + const params = (req.params ?? {}) as { + name?: string; + arguments?: unknown; + _meta?: { progressToken?: unknown }; + }; + const toolName = params.name; + const progressToken = params._meta?.progressToken; + + if (toolName === "memwal_login") { + // Returns near-instantly with the click-able URL. The listener + // stays alive in the background — see handleLoginToolCall for the + // rationale on not blocking. + void handleLoginToolCall(config, progressToken).then((result) => { + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: result.text }], + isError: result.isError, + }, + }); + }); + return null; + } + + // Any other memory tool. If `memwal_login` has since written + // credentials, hand off to the bridge so THIS call is served for real — + // no client restart (the historical "second reboot"). Otherwise nudge + // the agent to sign in first. + const creds = loadCreds(); + if (creds) return { creds }; + + writeStdoutMessage({ + jsonrpc: "2.0", + id, + result: { + content: [{ type: "text", text: LOGIN_INSTRUCTION }], + isError: true, + }, + }); + return null; + } + + // Anything else — return Method not found per JSON-RPC. + writeStdoutMessage({ + jsonrpc: "2.0", + id, + error: { + code: -32601, + message: `Method not found: ${method ?? "(missing)"}`, + }, + }); + return null; +} + /** - * Run the auth-required stdio MCP server. Returns when stdin closes. + * Run the auth-required stdio MCP server. + * + * Resolves with an {@link AuthHandoff} the moment `memwal_login` completes and + * the next memory tool call (or tools/list) arrives — so the caller can pick up + * the real bridge IN THE SAME PROCESS, eliminating the second client restart. + * Resolves with `undefined` if stdin closes before any sign-in. + * + * We manage the stdin listeners directly (rather than via the shared + * `readStdinLines`) so we can DETACH cleanly at handoff: the bridge attaches its + * own reader next, and two concurrent `data` listeners would double-process + * every line. `pause()` keeps any bytes that arrive during the switch buffered + * until the bridge's reader resumes the stream. * * The `config` parameter carries the same `relayerUrl` / `webUrl` / `label` * that the rest of the CLI resolved (e.g. `--dev` → dev URLs). Without it, - * `memwal_login` would fall back to prod defaults and open the wrong - * dashboard. + * `memwal_login` would fall back to prod defaults and open the wrong dashboard. */ -export async function runAuthRequiredServer(config: AuthRequiredConfig): Promise { +export async function runAuthRequiredServer( + config: AuthRequiredConfig, +): Promise { log.info("auth_required_server.started", { webUrl: config.webUrl, relayerUrl: config.relayerUrl, }); - await readStdinLines((line) => { - let req: RpcMessage; - try { - req = JSON.parse(line) as RpcMessage; - } catch { - return; - } + return await new Promise((resolve) => { + let buf = ""; + let settled = false; - // Notifications don't need a response. - if (req.id == null && typeof req.method === "string") { - return; - } + const detach = (): void => { + process.stdin.removeListener("data", onData); + process.stdin.removeListener("end", onEnd); + process.stdin.removeListener("close", onEnd); + }; - const id = req.id ?? null; - const method = req.method; - - if (method === "initialize") { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - protocolVersion: "2024-11-05", - capabilities: { tools: { listChanged: false } }, - serverInfo: { name: "memwal", version: "0.0.1" }, - }, - }); - return; - } + const onData = (chunk: string): void => { + buf += chunk; + let nl: number; + while ((nl = buf.indexOf("\n")) >= 0) { + const rawLine = buf.slice(0, nl).replace(/\r$/, ""); + buf = buf.slice(nl + 1); + if (rawLine.length === 0) continue; - if (method === "tools/list") { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { tools: TOOL_DEFINITIONS }, - }); - return; - } + const handoff = handleAuthLine(rawLine, config); + if (!handoff) continue; + + // Fresh credentials detected mid-session. Stop consuming stdin + // and hand the bridge everything we've read but not forwarded: + // the triggering line first, then anything buffered behind it. + settled = true; + detach(); + process.stdin.pause(); - if (method === "tools/call") { - const params = (req.params ?? {}) as { - name?: string; - arguments?: unknown; - _meta?: { progressToken?: unknown }; - }; - const toolName = params.name; - const progressToken = params._meta?.progressToken; - - if (toolName === "memwal_login") { - // Returns near-instantly with the click-able URL. The - // listener stays alive in the background — see - // handleLoginToolCall for the rationale on not blocking. - void handleLoginToolCall(config, progressToken).then((result) => { - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - content: [{ type: "text", text: result.text }], - isError: result.isError, - }, - }); + const pendingLines: string[] = [rawLine]; + let n2: number; + while ((n2 = buf.indexOf("\n")) >= 0) { + const l = buf.slice(0, n2).replace(/\r$/, ""); + buf = buf.slice(n2 + 1); + if (l.length > 0) pendingLines.push(l); + } + + log.info("auth_required_server.handoff_to_bridge", { + accountId: handoff.creds.accountId, + pendingLines: pendingLines.length, }); + resolve({ creds: handoff.creds, pendingLines }); return; } + }; - // Any other tool — fall through to the generic LOGIN_INSTRUCTION - // error so the agent knows it must call memwal_login first. - writeStdoutMessage({ - jsonrpc: "2.0", - id, - result: { - content: [{ type: "text", text: LOGIN_INSTRUCTION }], - isError: true, - }, - }); - return; - } + const onEnd = (): void => { + if (settled) return; + settled = true; + detach(); + log.info("auth_required_server.closed", {}); + resolve(undefined); + }; - // Anything else — return Method not found per JSON-RPC. - writeStdoutMessage({ - jsonrpc: "2.0", - id, - error: { - code: -32601, - message: `Method not found: ${method ?? "(missing)"}`, - }, - }); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", onData); + process.stdin.on("end", onEnd); + process.stdin.on("close", onEnd); }); - - log.info("auth_required_server.closed", {}); } diff --git a/packages/mcp/src/bridge.ts b/packages/mcp/src/bridge.ts index 2c6be5cd..c4275ec0 100644 --- a/packages/mcp/src/bridge.ts +++ b/packages/mcp/src/bridge.ts @@ -438,7 +438,16 @@ function handleLocalLogout(): { text: string; isError: boolean } { * file directly. They appear in `tools/list` by splicing them into the * relayer's response on the way back to the client. */ -export async function runBridge(creds: MemWalCredentials, config: BridgeConfig): Promise { +export async function runBridge( + creds: MemWalCredentials, + config: BridgeConfig, + /** Requests the auth-required server already read off stdin before it + * detected fresh credentials and handed control here (e.g. the + * `memwal_recall` that triggered the hot-handoff). Replayed once the SSE + * stream is up so they're served for real instead of being lost in the + * mode switch — this is what removes the historical "second restart". */ + pendingLines: string[] = [], +): Promise { note(`Connecting to ${creds.relayerUrl}...`); log.info("bridge.connecting", { relayer: creds.relayerUrl, @@ -561,7 +570,7 @@ export async function runBridge(creds: MemWalCredentials, config: BridgeConfig): // Client → server: forward stdin lines as POST messages. On 404 (the // relayer doesn't know our sessionId — happens right after a reconnect // if the message races the new handshake), trigger another reconnect. - const clientPump = readStdinLines((line) => { + const handleClientLine = (line: string): void => { void (async () => { try { const msg = JSON.parse(line) as RpcMessage; @@ -630,7 +639,18 @@ export async function runBridge(creds: MemWalCredentials, config: BridgeConfig): log.warn("bridge.stdin_parse_failed", { line: line.slice(0, 120) }); } })(); - }).then(() => { + }; + + // Replay anything the auth-required server handed off (the tool call that + // triggered the hot-handoff, plus anything buffered behind it) now that the + // SSE stream is connected. Without this the triggering request is dropped in + // the mode switch and the user has to retry / restart. + if (pendingLines.length > 0) { + log.info("bridge.replaying_handoff", { count: pendingLines.length }); + for (const line of pendingLines) handleClientLine(line); + } + + const clientPump = readStdinLines(handleClientLine).then(() => { stdinClosed = true; sse.abort(); }); diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 74fc2e99..ca50aef8 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -177,7 +177,22 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise Date: Fri, 5 Jun 2026 02:19:00 +0700 Subject: [PATCH 4/9] test(mcp): integration test for auth-required -> bridge hot-handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spawns the built MCP with an empty HOME (auth-required mode), asserts memwal_recall returns the not-signed-in instruction, writes a valid credentials.json mid-process (what the login callback does), then asserts the next memwal_recall is served for real against a mock relayer — no process restart. Adds an npm test script wired to node --test. --- packages/mcp/package.json | 1 + packages/mcp/test/login-handoff.test.mjs | 203 +++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 packages/mcp/test/login-handoff.test.mjs diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e48c0cb7..23d4648a 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -21,6 +21,7 @@ "build": "tsc", "dev": "tsc --watch", "typecheck": "tsc --noEmit", + "test": "npm run build && node --test \"test/**/*.test.mjs\"", "start": "node ./dist/bin/memwal-mcp.js" }, "dependencies": { diff --git a/packages/mcp/test/login-handoff.test.mjs b/packages/mcp/test/login-handoff.test.mjs new file mode 100644 index 00000000..2f13d647 --- /dev/null +++ b/packages/mcp/test/login-handoff.test.mjs @@ -0,0 +1,203 @@ +/** + * Integration test for auth-required → bridge hot-handoff (no second restart). + * + * Scenario (mirrors the "double reboot" bug): + * 1. Spawn memwal-mcp with an EMPTY ~/.memwal (HOME pointed at a temp dir) so + * it boots in auth-required mode. + * 2. `initialize` is answered locally; `memwal_recall` returns the + * not-signed-in instruction. + * 3. Write a valid credentials.json mid-process (what `memwal_login`'s + * browser callback does). + * 4. Call `memwal_recall` again — WITHOUT restarting the process — and assert + * it is served for real (forwarded to the relayer, real result back). + * + * A tiny mock relayer stands in for relayer.memory.walrus.xyz: it answers the + * `/version` compatibility probe and speaks the SSE transport the bridge needs. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import { spawn } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BIN = resolve(__dirname, "../dist/bin/memwal-mcp.js"); + +/** Minimal relayer: /version probe + SSE transport that echoes a recall reply. */ +function startMockRelayer() { + let sseRes = null; + const server = http.createServer((req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + if (req.method === "GET" && url.pathname === "/version") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + apiVersion: "1.0.0", + relayerVersion: "1.0.0", + minSupportedSdk: { mcp: "0.0.1" }, + }), + ); + return; + } + if (req.method === "GET" && url.pathname === "/api/mcp/sse") { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + // Tell the bridge where to POST outbound messages. + res.write("event: endpoint\ndata: /api/mcp/messages?sessionId=test\n\n"); + sseRes = res; + return; + } + if (req.method === "POST" && url.pathname === "/api/mcp/messages") { + let body = ""; + req.on("data", (c) => (body += c)); + req.on("end", () => { + res.writeHead(202); + res.end(); + let msg; + try { + msg = JSON.parse(body); + } catch { + return; + } + if (msg.method === "tools/call" && msg.params?.name === "memwal_recall") { + const reply = { + jsonrpc: "2.0", + id: msg.id, + result: { + content: [{ type: "text", text: "RECALL_OK: montreal trip" }], + isError: false, + }, + }; + sseRes?.write(`event: message\ndata: ${JSON.stringify(reply)}\n\n`); + } + }); + return; + } + res.writeHead(404); + res.end(); + }); + return new Promise((res) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + res({ server, base: `http://127.0.0.1:${port}` }); + }); + }); +} + +function makeCreds(relayerUrl) { + return { + delegatePrivateKey: "a".repeat(64), + delegatePublicKeyHex: "b".repeat(64), + delegateAddress: "0x" + "1".repeat(64), + walletAddress: "0x" + "2".repeat(64), + accountId: "0x" + "3".repeat(64), + packageId: "0x" + "4".repeat(64), + relayerUrl, + label: "Integration Test", + createdAt: new Date(0).toISOString(), + version: 1, + }; +} + +test("auth-required mode picks up credentials mid-session without a restart", async (t) => { + const { server, base } = await startMockRelayer(); + const home = mkdtempSync(join(tmpdir(), "memwal-test-")); + const credsPath = join(home, ".memwal", "credentials.json"); + + const child = spawn(process.execPath, [BIN, "--relayer", base, "--web-url", base], { + env: { ...process.env, HOME: home, USERPROFILE: home }, + stdio: ["pipe", "pipe", "pipe"], + }); + + const received = []; + const listeners = new Set(); + let buf = ""; + child.stdout.on("data", (d) => { + buf += d.toString(); + let nl; + while ((nl = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, nl); + buf = buf.slice(nl + 1); + if (!line.trim()) continue; + let msg; + try { + msg = JSON.parse(line); + } catch { + continue; + } + received.push(msg); + for (const l of [...listeners]) l(msg); + } + }); + + const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); + const waitFor = (pred, ms = 15000) => { + const hit = received.find(pred); + if (hit) return Promise.resolve(hit); + return new Promise((res, rej) => { + const timer = setTimeout(() => { + listeners.delete(l); + rej(new Error("timed out waiting for message")); + }, ms); + const l = (m) => { + if (pred(m)) { + clearTimeout(timer); + listeners.delete(l); + res(m); + } + }; + listeners.add(l); + }); + }; + + t.after(() => { + child.kill("SIGKILL"); + server.close(); + rmSync(home, { recursive: true, force: true }); + }); + + // 1. initialize — answered locally by the auth-required server. + send({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }); + const init = await waitFor((m) => m.id === 1 && m.result); + assert.equal(init.result.serverInfo.name, "memwal"); + + // 2. recall before login → not-signed-in instruction. + send({ + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "montreal" } }, + }); + const before = await waitFor((m) => m.id === 2); + assert.equal(before.result.isError, true, "should be an error before login"); + assert.match( + JSON.stringify(before.result), + /isn't signed in|not signed in/i, + "should nudge the user to log in", + ); + + // 3. Login completes: write credentials into the same process's HOME. + mkdirSync(dirname(credsPath), { recursive: true }); + writeFileSync(credsPath, JSON.stringify(makeCreds(base)), { mode: 0o600 }); + + // 4. recall again, same process, no restart → served for real via the relayer. + send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "memwal_recall", arguments: { query: "montreal" } }, + }); + const after = await waitFor((m) => m.id === 3); + assert.notEqual(after.result.isError, true, "recall should succeed after login"); + assert.match( + JSON.stringify(after.result), + /RECALL_OK/, + "recall result should come from the relayer, not the login stub", + ); +}); From b6942a9ec028e689c09ea77a210fd4f9fdb8ce73 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 5 Jun 2026 02:25:15 +0700 Subject: [PATCH 5/9] ci(mcp): run MCP integration tests on PRs and gate publish - test.yml: add a 'MCP / Integration (login handoff)' job that runs the node:test suite on every pull_request/push to main/staging/dev, matching the chatbot/server e2e pattern already in the workflow. - release-mcp.yml: run the same suite after build, before publish, so the package is never shipped without the hot-handoff/callback e2e passing. - package.json: test script uses tsc directly (no nested npm) so it runs cleanly under pnpm --filter. --- .github/workflows/release-mcp.yml | 5 +++++ .github/workflows/test.yml | 28 ++++++++++++++++++++++++++++ packages/mcp/package.json | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-mcp.yml b/.github/workflows/release-mcp.yml index fa71a75b..920ec448 100644 --- a/.github/workflows/release-mcp.yml +++ b/.github/workflows/release-mcp.yml @@ -45,6 +45,11 @@ jobs: - name: Build MCP package run: pnpm --filter @mysten-incubation/memwal-mcp build + # Gate every publish on the integration suite (auth-required -> bridge + # hot-handoff, login callback). Never ship the MCP package without it. + - name: Test MCP package + run: pnpm --filter @mysten-incubation/memwal-mcp test + # ── main branch → changeset version + stable release (latest) ── - name: Apply changesets (update version & CHANGELOG) if: github.ref == 'refs/heads/main' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c881cd6a..a210204d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,6 +32,34 @@ jobs: - name: Check compatibility contract run: node scripts/check-compatibility-contract.mjs + mcp-tests: + name: MCP / Integration (login handoff) + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + # Builds the package (tsc) then runs the node:test integration suite: + # spawns the real stdio MCP with empty creds, writes credentials + # mid-process, and asserts memwal_recall is served against a mock relayer + # without a restart (auth-required -> bridge hot-handoff). + - name: MCP integration tests + run: pnpm --filter @mysten-incubation/memwal-mcp test + chatbot-e2e: name: Chatbot / Playwright E2E runs-on: ubuntu-latest diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 23d4648a..4132bbf2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -21,7 +21,7 @@ "build": "tsc", "dev": "tsc --watch", "typecheck": "tsc --noEmit", - "test": "npm run build && node --test \"test/**/*.test.mjs\"", + "test": "tsc && node --test \"test/**/*.test.mjs\"", "start": "node ./dist/bin/memwal-mcp.js" }, "dependencies": { From d618bb53a29fe2b7718df42c3409db7ea58a750d Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Fri, 5 Jun 2026 02:54:24 +0700 Subject: [PATCH 6/9] docs(mcp): add 0.0.4 changelog entry Mirror the entry across packages/mcp/CHANGELOG.md and docs/mcp/changelog.mdx (same pattern as #238): PNA loopback callback fix + auth-required credential reload (no second restart). --- docs/mcp/changelog.mdx | 7 +++++++ packages/mcp/CHANGELOG.md | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/docs/mcp/changelog.mdx b/docs/mcp/changelog.mdx index 10e08794..384bda15 100644 --- a/docs/mcp/changelog.mdx +++ b/docs/mcp/changelog.mdx @@ -3,6 +3,13 @@ title: "Changelog" description: "Release history for the Walrus Memory MCP package." --- +## 0.0.4 + +### Fixed + +- Accept the browser sign-in callback on `127.0.0.1` when the dashboard is served over HTTPS. The local login listener now answers Chrome's Private Network Access preflight (`Access-Control-Allow-Private-Network`), so the on-chain registration no longer succeeds while the callback is silently blocked. +- Pick up credentials without a second restart. After `memwal_login` writes `~/.memwal/credentials.json`, the next memory tool call hands off to the bridge in the same process instead of reporting "not signed in" until the client is restarted. + ## 0.0.3 ### Changed diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index aa3c3c01..b80cc064 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,12 @@ # @mysten-incubation/memwal-mcp +## 0.0.4 + +### Fixed + +- Accept the browser sign-in callback on `127.0.0.1` when the dashboard is served over HTTPS. The local login listener now answers Chrome's Private Network Access preflight (`Access-Control-Allow-Private-Network`), so the on-chain registration no longer succeeds while the callback is silently blocked. +- Pick up credentials without a second restart. After `memwal_login` writes `~/.memwal/credentials.json`, the next memory tool call hands off to the bridge in the same process instead of reporting "not signed in" until the client is restarted. + ## 0.0.3 ### Changed From 84ead2e055c239d3f5ac5639cfe579d69ffc3e10 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Fri, 5 Jun 2026 13:40:24 +0700 Subject: [PATCH 7/9] docs(mcp): tighten changelog entries --- docs/mcp/changelog.mdx | 4 ++-- packages/mcp/CHANGELOG.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp/changelog.mdx b/docs/mcp/changelog.mdx index 384bda15..4694b822 100644 --- a/docs/mcp/changelog.mdx +++ b/docs/mcp/changelog.mdx @@ -7,8 +7,8 @@ description: "Release history for the Walrus Memory MCP package." ### Fixed -- Accept the browser sign-in callback on `127.0.0.1` when the dashboard is served over HTTPS. The local login listener now answers Chrome's Private Network Access preflight (`Access-Control-Allow-Private-Network`), so the on-chain registration no longer succeeds while the callback is silently blocked. -- Pick up credentials without a second restart. After `memwal_login` writes `~/.memwal/credentials.json`, the next memory tool call hands off to the bridge in the same process instead of reporting "not signed in" until the client is restarted. +- Accept HTTPS dashboard sign-in callbacks to the local `127.0.0.1` MCP listener. +- Reload credentials after `memwal_login` so memory tools work without restarting the MCP client. ## 0.0.3 diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index b80cc064..42efb750 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -4,8 +4,8 @@ ### Fixed -- Accept the browser sign-in callback on `127.0.0.1` when the dashboard is served over HTTPS. The local login listener now answers Chrome's Private Network Access preflight (`Access-Control-Allow-Private-Network`), so the on-chain registration no longer succeeds while the callback is silently blocked. -- Pick up credentials without a second restart. After `memwal_login` writes `~/.memwal/credentials.json`, the next memory tool call hands off to the bridge in the same process instead of reporting "not signed in" until the client is restarted. +- Accept HTTPS dashboard sign-in callbacks to the local `127.0.0.1` MCP listener. +- Reload credentials after `memwal_login` so memory tools work without restarting the MCP client. ## 0.0.3 From 0c0be42b7caf907dfff9d37f5c1490425a198b7b Mon Sep 17 00:00:00 2001 From: ducnmm Date: Fri, 5 Jun 2026 13:44:50 +0700 Subject: [PATCH 8/9] fix(server): harden Walrus upload retries --- .../scripts/__tests__/enoki-retry.test.ts | 22 ++++++++ services/server/scripts/enoki-retry.ts | 8 +++ services/server/scripts/sidecar-server.ts | 43 +++++++++------ services/server/src/storage/walrus.rs | 2 +- services/server/src/types.rs | 52 ++++++++++++++++++- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/services/server/scripts/__tests__/enoki-retry.test.ts b/services/server/scripts/__tests__/enoki-retry.test.ts index 1417bd0f..988656b8 100644 --- a/services/server/scripts/__tests__/enoki-retry.test.ts +++ b/services/server/scripts/__tests__/enoki-retry.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { getEnokiRetryDelayMs, + isSponsoredTransactionInvalidatedMessage, isTransientEnokiStatus, parseRetryAfterMs, parseTryAgainBodyDelayMs, @@ -74,3 +75,24 @@ test("retries transport errors with exponential fallback", () => { transportError: true, }), 5_000); }); + +test("detects invalidated sponsored transaction responses", () => { + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (400): {"errors":[{"code":"expired","message":"Sponsored transaction has expired"}]}', + ), + true, + ); + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (404): {"errors":[{"code":"not_found","message":"Sponsored transaction not found"}]}', + ), + true, + ); + assert.equal( + isSponsoredTransactionInvalidatedMessage( + 'Enoki API error (400): {"errors":[{"code":"dry_run_failed"}]}', + ), + false, + ); +}); diff --git a/services/server/scripts/enoki-retry.ts b/services/server/scripts/enoki-retry.ts index 9ab55dff..61c5f6ba 100644 --- a/services/server/scripts/enoki-retry.ts +++ b/services/server/scripts/enoki-retry.ts @@ -47,6 +47,14 @@ export function parseTryAgainBodyDelayMs(body: string | undefined): number | nul return Math.round(amount * multiplier); } +export function isSponsoredTransactionInvalidatedMessage(message: string | undefined): boolean { + if (!message) return false; + return /sponsored transaction has expired/i.test(message) + || /"code"\s*:\s*"expired"/i.test(message) + || /sponsored transaction not found/i.test(message) + || /"code"\s*:\s*"not_found"/i.test(message); +} + export function getEnokiRetryDelayMs(input: EnokiRetryInput): number | null { if (input.attempt >= input.maxAttempts) return null; diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index 9d84dcba..bfa70228 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -21,7 +21,7 @@ import { SealClient, SessionKey, EncryptedObject } from "@mysten/seal"; import { WalrusClient } from "@mysten/walrus"; import { mountMcpRoutes, shutdownMcpSessions } from "./mcp/index.js"; import { getSealServerConfigsFromEnv, getSealThresholdFromEnv } from "./seal-config.js"; -import { getEnokiRetryDelayMs } from "./enoki-retry.js"; +import { getEnokiRetryDelayMs, isSponsoredTransactionInvalidatedMessage } from "./enoki-retry.js"; import { isWalrusBlobObjectMissingFromEffects, isWalrusObjectLockEquivocation, @@ -81,12 +81,31 @@ const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( ); const MAX_WALRUS_EPOCHS = 5; +const NETWORK_DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "mainnet" ? 3 : MAX_WALRUS_EPOCHS; const DEFAULT_WALRUS_EPOCHS = (() => { - const parsed = Number.parseInt(process.env.WALRUS_STORAGE_EPOCHS || "", 10); - if (Number.isFinite(parsed) && parsed > 0) { - return Math.min(parsed, MAX_WALRUS_EPOCHS); + const raw = process.env.WALRUS_STORAGE_EPOCHS?.trim(); + if (!raw) { + return NETWORK_DEFAULT_WALRUS_EPOCHS; } - return SUI_NETWORK === "mainnet" ? 3 : MAX_WALRUS_EPOCHS; + + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0 && parsed <= MAX_WALRUS_EPOCHS) { + return parsed; + } + + if (Number.isFinite(parsed) && parsed > MAX_WALRUS_EPOCHS) { + console.warn( + `[sidecar] WALRUS_STORAGE_EPOCHS=${raw} exceeds max ${MAX_WALRUS_EPOCHS}; ` + + `using network default ${NETWORK_DEFAULT_WALRUS_EPOCHS}`, + ); + } else { + console.warn( + `[sidecar] ignoring invalid WALRUS_STORAGE_EPOCHS=${raw}; ` + + `using network default ${NETWORK_DEFAULT_WALRUS_EPOCHS}`, + ); + } + + return NETWORK_DEFAULT_WALRUS_EPOCHS; })(); const SUI_RPC_URL = getJsonRpcFullnodeUrl(SUI_NETWORK); @@ -316,7 +335,7 @@ const WALRUS_UPLOAD_ACQUIRE_TIMEOUT_MS = parsePositiveIntEnv( 1_000, 180_000, ); -const WALRUS_UPLOAD_EFFECTS_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000] as const; +const WALRUS_UPLOAD_EFFECTS_RETRY_DELAYS_MS = [2_000, 5_000, 10_000, 20_000, 40_000] as const; const walrusUploadGlobalLimiter = new AsyncSemaphore(WALRUS_UPLOAD_MAX_CONCURRENCY); const walrusUploadWalletLimiters = new Map(); @@ -626,7 +645,7 @@ async function callEnoki(path: string, payload: unknown): Promise { } for (let attempt = 1; ; attempt += 1) { - let resp: Response; + let resp: globalThis.Response; try { resp = await fetch(`${ENOKI_API_BASE_URL}${path}`, { method: "POST", @@ -689,12 +708,6 @@ async function callEnoki(path: string, payload: unknown): Promise { } } -function isSponsoredTransactionExpired(err: unknown): boolean { - const msg = errorMessage(err); - return /sponsored transaction has expired/i.test(msg) - || /"code"\s*:\s*"expired"/i.test(msg); -} - async function executeSponsoredTransactionOnce( tx: Transaction, signer: Ed25519Keypair, @@ -747,8 +760,8 @@ async function executeWithEnokiSponsor(tx: Transaction, signer: Ed25519Keypair, try { return await executeSponsoredTransactionOnce(tx, signer, allowedAddresses); } catch (err: any) { - if (isSponsoredTransactionExpired(err)) { - console.warn(`[enoki-sponsor] sponsored tx expired; retrying sponsor/execute once: ${err?.message || err}`); + if (isSponsoredTransactionInvalidatedMessage(errorMessage(err))) { + console.warn(`[enoki-sponsor] sponsored tx invalidated; retrying sponsor/execute once: ${err?.message || err}`); try { return await executeSponsoredTransactionOnce(tx, signer, allowedAddresses); } catch (retryErr: any) { diff --git a/services/server/src/storage/walrus.rs b/services/server/src/storage/walrus.rs index 5b977f74..6b9bedb1 100644 --- a/services/server/src/storage/walrus.rs +++ b/services/server/src/storage/walrus.rs @@ -3,7 +3,7 @@ use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; use futures::stream::{FuturesUnordered, StreamExt}; use std::time::Duration; -const SIDECAR_WALRUS_TIMEOUT: Duration = Duration::from_secs(180); +const SIDECAR_WALRUS_TIMEOUT: Duration = Duration::from_secs(300); const WALRUS_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(15); /// Result of a Walrus blob upload diff --git a/services/server/src/types.rs b/services/server/src/types.rs index fd56c2d6..91833bb7 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -43,7 +43,17 @@ pub(crate) fn configured_walrus_storage_epochs(network: &str) -> u32 { .ok() .and_then(|raw| raw.trim().parse::().ok()) { - Some(epochs) if epochs > 0 => epochs.min(MAX_WALRUS_STORAGE_EPOCHS), + Some(epochs) if epochs > 0 && epochs <= MAX_WALRUS_STORAGE_EPOCHS => epochs, + Some(epochs) if epochs > MAX_WALRUS_STORAGE_EPOCHS => { + tracing::warn!( + "WALRUS_STORAGE_EPOCHS={} exceeds max {}; using default {} for {}", + epochs, + MAX_WALRUS_STORAGE_EPOCHS, + default, + network, + ); + default + } _ => default, } } @@ -1104,6 +1114,28 @@ pub struct SidecarError { #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex; + + static WALRUS_STORAGE_EPOCHS_ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn with_walrus_storage_epochs_env(value: Option<&str>, test: impl FnOnce() -> R) -> R { + let _guard = WALRUS_STORAGE_EPOCHS_ENV_LOCK.lock().unwrap(); + let previous = std::env::var("WALRUS_STORAGE_EPOCHS").ok(); + + match value { + Some(value) => std::env::set_var("WALRUS_STORAGE_EPOCHS", value), + None => std::env::remove_var("WALRUS_STORAGE_EPOCHS"), + } + + let result = test(); + + match previous { + Some(value) => std::env::set_var("WALRUS_STORAGE_EPOCHS", value), + None => std::env::remove_var("WALRUS_STORAGE_EPOCHS"), + } + + result + } // ── AuthInfo Debug redacts delegate_key ─────────────────────── @@ -1346,6 +1378,24 @@ mod tests { ); } + #[test] + fn configured_walrus_storage_epochs_uses_valid_env_value() { + with_walrus_storage_epochs_env(Some("4"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 4); + }); + } + + #[test] + fn configured_walrus_storage_epochs_falls_back_when_env_exceeds_cap() { + with_walrus_storage_epochs_env(Some("15"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 3); + assert_eq!( + configured_walrus_storage_epochs("testnet"), + MAX_WALRUS_STORAGE_EPOCHS + ); + }); + } + // ── AppError Display implementations ──────────────────────────────── #[test] From 4b42e1d7168817381ebbf63bb65212f6bb613305 Mon Sep 17 00:00:00 2001 From: ducnmm Date: Fri, 5 Jun 2026 13:46:58 +0700 Subject: [PATCH 9/9] fix(server): honor explicit Walrus storage epochs --- services/server/scripts/sidecar-server.ts | 5 +++-- services/server/src/types.rs | 24 ++++++++++++++--------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/services/server/scripts/sidecar-server.ts b/services/server/scripts/sidecar-server.ts index bfa70228..00f75ba3 100644 --- a/services/server/scripts/sidecar-server.ts +++ b/services/server/scripts/sidecar-server.ts @@ -80,8 +80,9 @@ const WALRUS_UPLOAD_RELAY_URL = process.env.WALRUS_UPLOAD_RELAY_URL || ( : "https://upload-relay.mainnet.walrus.space" ); -const MAX_WALRUS_EPOCHS = 5; -const NETWORK_DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "mainnet" ? 3 : MAX_WALRUS_EPOCHS; +const MAX_WALRUS_EPOCHS = 15; +const DEFAULT_TESTNET_WALRUS_EPOCHS = 5; +const NETWORK_DEFAULT_WALRUS_EPOCHS = SUI_NETWORK === "mainnet" ? 3 : DEFAULT_TESTNET_WALRUS_EPOCHS; const DEFAULT_WALRUS_EPOCHS = (() => { const raw = process.env.WALRUS_STORAGE_EPOCHS?.trim(); if (!raw) { diff --git a/services/server/src/types.rs b/services/server/src/types.rs index 91833bb7..6b181f8c 100644 --- a/services/server/src/types.rs +++ b/services/server/src/types.rs @@ -27,13 +27,14 @@ pub const DEFAULT_BLOB_CACHE_MAX_BYTES: usize = 512 * 1024; /// Default max age for Redis-cached recall query embeddings. pub const DEFAULT_EMBEDDING_CACHE_TTL_SECS: u64 = 10 * 60; -/// Sidecar caps Walrus storage purchases to avoid accidental large spends. -pub const MAX_WALRUS_STORAGE_EPOCHS: u32 = 5; +/// Upper bound for explicit Walrus storage purchases. +pub const MAX_WALRUS_STORAGE_EPOCHS: u32 = 15; +pub const DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS: u32 = 5; pub(crate) fn default_walrus_storage_epochs_for_network(network: &str) -> u32 { match network { "mainnet" => 3, - _ => MAX_WALRUS_STORAGE_EPOCHS, + _ => DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS, } } @@ -1374,7 +1375,7 @@ mod tests { assert_eq!(default_walrus_storage_epochs_for_network("mainnet"), 3); assert_eq!( default_walrus_storage_epochs_for_network("testnet"), - MAX_WALRUS_STORAGE_EPOCHS + DEFAULT_TESTNET_WALRUS_STORAGE_EPOCHS ); } @@ -1386,13 +1387,18 @@ mod tests { } #[test] - fn configured_walrus_storage_epochs_falls_back_when_env_exceeds_cap() { + fn configured_walrus_storage_epochs_honors_explicit_fifteen() { with_walrus_storage_epochs_env(Some("15"), || { + assert_eq!(configured_walrus_storage_epochs("mainnet"), 15); + assert_eq!(configured_walrus_storage_epochs("testnet"), 15); + }); + } + + #[test] + fn configured_walrus_storage_epochs_falls_back_when_env_exceeds_cap() { + with_walrus_storage_epochs_env(Some("16"), || { assert_eq!(configured_walrus_storage_epochs("mainnet"), 3); - assert_eq!( - configured_walrus_storage_epochs("testnet"), - MAX_WALRUS_STORAGE_EPOCHS - ); + assert_eq!(configured_walrus_storage_epochs("testnet"), 5); }); }