Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
ba6f875
docs(ops): migration completion artifact schema (COMG-718)
ducnmm Aug 31, 2026
79e75a5
docs(ops): security RC review workflow (COMG-718)
ducnmm Aug 31, 2026
378e636
docs: fix ops docs style-guide nits (COMG-718)
ducnmm Aug 31, 2026
11c93a2
docs(ops): use active voice in Security RC review workflow (COMG-718)
hien-p Sep 8, 2026
b2a0fb0
fix(scripts): validate packageId and refuse to clobber the artifact (…
hien-p Sep 8, 2026
feda593
fix(scripts): reject unknown flags in the completion artifact writer …
hien-p Sep 9, 2026
a403340
Merge pull request #842 from MystenLabs/henrynguyen/comg-718-migratio…
harrymove-ctrl Sep 9, 2026
6e30146
fix(chatbot): stop guest-auth redirect loop on Railway bind address (…
ducnmm Sep 9, 2026
7bca370
fix(server): report restore decrypt failures instead of skipped (COMG…
ducnmm Sep 9, 2026
139e455
fix(sdk): stop telling headless clients to call memwal_login (WALM-45…
ducnmm Sep 9, 2026
fbbf525
fix(sdk): use typed tx.pure helpers in account and manual PTBs (WALM-…
ducnmm Sep 9, 2026
60c2a64
fix(relayer): Slack and write_ready when Postgres storage is exhauste…
ducnmm Sep 10, 2026
5a21437
fix(sdk): stop importing a Node builtin on a browser path (WALM-136) …
nikola0x0 Sep 10, 2026
1439e97
fix(mcp): warn on unrecognised flags, document env presets, report re…
nikola0x0 Sep 11, 2026
9b80bfe
fix(relayer): cache delegate-key verification so one tool call is one…
hien-p Sep 11, 2026
493c9e6
fix(mcp): confirm a completed sign-in, and keep serving stdin after i…
nikola0x0 Sep 11, 2026
4b157db
fix(relayer): sweep the verify cache on its own TTL (WALM-618 review)
hien-p Sep 11, 2026
517c369
fix(relayer): log a rejected MCP delegate key at warn with its account
hien-p Sep 12, 2026
4b82d59
fix(relayer): serve a known-good delegate key while Sui is unreachable
hien-p Sep 12, 2026
255c0e4
fix(relayer): stop a refused delegate key costing a Sui read per retry
hien-p Sep 12, 2026
7d41975
fix(mcp): honour Retry-After on a throttled SSE handshake (WALM-386)
hien-p Sep 7, 2026
4393cfc
fix(mcp): say the handshake failed instead of blaming a dropped reply…
hien-p Sep 11, 2026
6bc8c10
fix(mcp): end the silent wait when nothing was ever sent (WALM-618 re…
hien-p Sep 11, 2026
ca53225
docs(mcp): document the stalled-handshake deadline and its override
hien-p Sep 12, 2026
cc84d98
feat(mcp,relayer): make a slow MCP connect visible in logs and metrics
hien-p Sep 12, 2026
73cee0e
fix(relayer): address ducnmm's review on the verify cache (WALM-618)
hien-p Sep 12, 2026
75803ef
test(relayer): pin Keep as the guard for a concurrent successful verify
hien-p Sep 12, 2026
7979132
perf(relayer): probe the delegate caches without allocating on a hit
hien-p Sep 12, 2026
a3d8c38
fix(relayer): reconnect Redis so account setup is not 503 (WALM-626) …
ducnmm Sep 14, 2026
22f660d
docs(mcp): file WALM-386/WALM-618 under unpublished 0.0.13
hien-p Sep 14, 2026
0c63557
fix(relayer): label the MCP handshake metric by route (WALM-618 review)
hien-p Sep 14, 2026
91ab282
test(relayer): pin the verify-cache sweep against the grace, not the TTL
hien-p Sep 14, 2026
bef0487
fix(relayer): bump the eviction generation under the entries lock
hien-p Sep 14, 2026
16a038b
fix(relayer): forward Retry-After on a proxied MCP 429 (WALM-386)
hien-p Sep 14, 2026
b8a5c5f
fix(mcp): track never-sent on the request, not on pendingForward (WAL…
hien-p Sep 14, 2026
c832beb
fix(mcp): ignore a non-positive Retry-After instead of hammering (WAL…
hien-p Sep 14, 2026
bc531e5
fix(relayer): expire rejections before the cap, and stop a success er…
hien-p Sep 14, 2026
806ff2e
fix(relayer,mcp): forward Retry-After on messages too, and pin the mi…
hien-p Sep 14, 2026
170e0ce
fix(relayer): sample the stale-serve log, and sweep the last two unbo…
hien-p Sep 14, 2026
7aee187
test(mcp): wait for the bridge to observe the outage before sending t…
hien-p Sep 14, 2026
82e44d0
test(mcp): drop the mid-session stalled-deadline test, unpinned for now
hien-p Sep 14, 2026
254329d
test(mcp): pin the mid-session stalled deadline, synchronised on the …
hien-p Sep 14, 2026
43109b5
fix(mcp): a stale-session 404 returns the call to never-sent (WALM-618)
hien-p Sep 14, 2026
4a6ee54
fix(mcp): tell the client its credentials were rejected (WALM-602) (#…
nikola0x0 Sep 14, 2026
655e4cc
Merge dev into walm-618 handshake verify cache
hien-p Sep 14, 2026
8acc68a
Merge pull request #900 from MystenLabs/harryphan/walm-618-handshake-…
harrymove-ctrl Sep 14, 2026
1164b48
fix(researcher): use Sui gRPC in the browser so Enoki login survives …
nikola0x0 Sep 14, 2026
c78d60b
fix(mcp): pin memwal-mcp version in plugin npx (WALM-627)
ducnmm Sep 14, 2026
427c972
fix(mcp): pin Codex fallback installer npx version (WALM-627)
ducnmm Sep 14, 2026
eceded5
fix(mcp): read plugin.json for Codex npx pin (WALM-627)
ducnmm Sep 14, 2026
07e6979
fix(mcp): write credentials through a fresh 0600 inode instead of chm…
nikola0x0 Sep 15, 2026
ef35f2c
fix(relayer,mcp): stop naming the network from costing a round trip t…
hien-p Sep 14, 2026
e59bece
fix(relayer,mcp): dial loopback for MCP tool calls instead of the dep…
hien-p Sep 14, 2026
8834755
fix(mcp): stop advising a retry that duplicates a paid write, and pin…
hien-p Sep 15, 2026
27e48aa
test(mcp): add a live acceptance run for the field report, case by case
hien-p Sep 15, 2026
195e9b6
fix(mcp): redact the dial URL in sidecar logs, fix the settle pairing…
hien-p Sep 15, 2026
4f2bad7
fix(mcp): sync the docs changelog, and leave the launcher pin to #913
hien-p Sep 15, 2026
84d12e9
Merge pull request #912 from MystenLabs/harry/walm-mcp-sidecar-hairpin
harrymove-ctrl Sep 15, 2026
b0a8eeb
Merge branch 'dev' into henrynguyen/walm-627-plugin-launches-unpinned…
hien-p Sep 15, 2026
bfe3036
Merge pull request #913 from MystenLabs/henrynguyen/walm-627-plugin-l…
harrymove-ctrl Sep 15, 2026
cbbc9c6
Merge pull request #909 from MystenLabs/dev
harrymove-ctrl Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"name": "memwal",
"source": "./packages/mcp/plugin",
"description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"version": "0.0.12"
"version": "0.0.13"
}
]
}
2 changes: 1 addition & 1 deletion .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"name": "memwal",
"source": "./packages/mcp/plugin",
"description": "Automatic Walrus Memory — proactive recall and durable-fact saving via the MemWal MCP + lifecycle hooks.",
"version": "0.0.12"
"version": "0.0.13"
}
]
}
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ jobs:
script: scripts/check-docs-freshness.mjs
- name: SEAL cross-account synthetic parser
script: scripts/synthetic-seal-cross-account.test.mjs
- name: Migration / Completion artifact
script: scripts/write-migration-completion-artifact.mjs --self-test

steps:
- uses: actions/checkout@v4
Expand Down
8 changes: 5 additions & 3 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ const stored = await memwal.waitForRememberJob(accepted.job_id, {
| `recall({ query, limit?, topK?, namespace?, maxDistance? })` *(preferred)* or `recall(query, limit?, namespace?)` | Semantic search for memories | `{ results: [{ blob_id, text, distance }], total }` |
| `analyze(text, namespace?)` | Extract facts and accept one memory job per fact | `{ job_ids, facts, fact_count, status, owner }` |
| `analyzeAndWait(text, namespace?, opts?)` | Extract facts and wait for all fact jobs to complete | `{ results, facts, total, succeeded, failed, owner }` |
| `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, total, namespace, owner, truncated }` |
| `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, failed, total, namespace, owner, truncated }` |
| `health()` | Check relayer health | `{ status, version }` |
| `getPublicKeyHex()` | Get hex-encoded public key | `string` |

Expand Down Expand Up @@ -271,6 +271,7 @@ interface EmbedResult {
interface RestoreResult {
restored: number;
skipped: number;
failed: number;
total: number;
namespace: string;
owner: string;
Expand Down Expand Up @@ -363,15 +364,16 @@ Cross-namespace and cross-owner reads are not just filtered out of results — t
| Field | Counts | Notes |
|---|---|---|
| `restored` | Blobs the relayer just rebuilt this call | Pulled from Walrus → SEAL decrypted → re-embedded → inserted as a new row |
| `skipped` | On-chain blobs already in the local index | No work needed; relayer left them as-is |
| `skipped` | On-chain blobs already in the local **success** index | No work needed; relayer left them as-is. Does not include decrypt/UTF-8 failures. |
| `failed` | Permanent decrypt/UTF-8 failures | On-chain blobs in this page that are negative-cached, plus new permanent failures this call. Older relayers omit the field; SDKs default it to `0`. |
| `total` | All on-chain blobs the relayer saw for `(owner, namespace)` | Before the limit was applied |
| `namespace` | Echo of the request | |
| `owner` | Resolved owner address | |
| `truncated` | Known-retryable-incomplete | `true` is not a hard failure; `false` is not completeness |

`truncated=true` means this restore is **known-retryable-incomplete**: more missing blobs than `limit` allowed this call to restore, **or** the sidecar's owner-wide candidate fetch hit its cap **and** raising `limit` can still expand that fetch (`limit < 20`). Once the sidecar cap is saturated (`limit >= 20`, cap pinned at 100), truncation follows this call's missing-blob page length, not onchain `total`. A fully restored namespace does not loop. `truncated=false` is **not** proof the sidecar saw every onchain blob; blobs beyond the owner-wide sidecar candidate cap can still be missing. WALM-451 tracks a `sourceCapped` field for that case. Relayers older than WALM-319 omit `truncated`; SDKs default it to `false`.

**Silent drops.** A blob that *cannot* be decrypted or embedded (e.g. wrong delegate key, malformed ciphertext, embedding API down) is dropped without counting in `restored` *or* `skipped`. `restored + skipped` is therefore a lower bound on healthy entries, not a strict equality with `total`.
Permanent decrypt or invalid-UTF-8 failures count in `failed`, not `skipped`. Transient download/decrypt/embed errors are still not counted in `restored`, `skipped`, or `failed` and may be retried (`truncated=true` when a page yields only those). `restored + skipped + failed` therefore never exceeds `total`, and falls short of it whenever transient errors leave blobs uncounted.

#### Default and limit

Expand Down
31 changes: 5 additions & 26 deletions apps/chatbot/app/(auth)/api/auth/guest/route.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,22 @@
import { NextResponse } from "next/server";
import { signIn } from "@/app/(auth)/auth";
import { isSafeRedirectUrl, publicRequestUrl } from "@/lib/public-request-url";
import { getSessionToken } from "@/lib/session-token";

/**
* Validate a redirect target before forwarding to auth.
* Allows only:
* - Relative paths beginning with "/" (but not "//", which is protocol-relative)
* - Absolute URLs whose origin matches the request origin (same-origin)
* Anything else (external hosts, javascript:, data:, //evil.com) falls back to "/".
*/
function isSafeRedirectUrl(redirectUrl: string, requestUrl: string): boolean {
// Relative path — safe as long as it isn't protocol-relative ("//host/...")
if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) {
return true;
}
// Absolute URL — must share the same origin as the request
try {
const redirectOrigin = new URL(redirectUrl).origin;
const requestOrigin = new URL(requestUrl).origin;
return redirectOrigin === requestOrigin;
} catch {
// Unparseable URL (e.g. "javascript:alert(1)") — reject
return false;
}
}

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const rawRedirectUrl = searchParams.get("redirectUrl") || "/";
const publicUrl = publicRequestUrl(request);

// Reject cross-origin or protocol-relative redirect targets
const redirectUrl = isSafeRedirectUrl(rawRedirectUrl, request.url)
// Reject cross-origin, bind-address, or protocol-relative redirect targets
const redirectUrl = isSafeRedirectUrl(rawRedirectUrl, request)
? rawRedirectUrl
: "/";

const token = await getSessionToken(request);

if (token) {
return NextResponse.redirect(new URL("/", request.url));
return NextResponse.redirect(new URL("/", publicUrl));
}

return signIn("guest", { redirect: true, redirectTo: redirectUrl });
Expand Down
2 changes: 2 additions & 0 deletions apps/chatbot/app/(auth)/auth.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { NextAuthConfig } from "next-auth";

export const authConfig = {
// Railway / Docker set HOSTNAME=0.0.0.0; trust the incoming Host header.
trustHost: true,
pages: {
signIn: "/login",
newUser: "/",
Expand Down
79 changes: 79 additions & 0 deletions apps/chatbot/lib/public-request-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const BIND_HOSTNAMES = new Set(["0.0.0.0", "::", "[::]"]);

function isBindHostname(hostname: string): boolean {
return BIND_HOSTNAMES.has(hostname.toLowerCase());
}

function firstHeader(headers: Headers, name: string): string | null {
return headers.get(name)?.split(",")[0]?.trim() || null;
}

function usablePublicHost(host: string | null): string | null {
if (!host) {
return null;
}
try {
const hostname = new URL(`http://${host}`).hostname;
return hostname && !isBindHostname(hostname) ? host : null;
} catch {
return null;
}
}

export function publicRequestUrl(request: Request): URL {
const url = new URL(request.url);
const forwardedHost = usablePublicHost(
firstHeader(request.headers, "x-forwarded-host")
);
const publicHost =
forwardedHost ??
(isBindHostname(url.hostname)
? usablePublicHost(firstHeader(request.headers, "host"))
: null);

if (!publicHost) {
return url;
}

const proto = firstHeader(request.headers, "x-forwarded-proto")?.toLowerCase();
const protocol =
proto === "http" || proto === "https"
? proto
: url.protocol.replace(/:$/, "");

// Reconstruct; assigning URL.host keeps :3000 from the bind address.
try {
return new URL(`${protocol}://${publicHost}${url.pathname}${url.search}`);
} catch {
return url;
}
}

export function guestReturnPath(request: Request): string {
const url = new URL(request.url);
const path = `${url.pathname}${url.search}`;
return path.startsWith("/") && !path.startsWith("//") ? path : "/";
}

export function isSafeRedirectUrl(
redirectUrl: string,
request: Request
): boolean {
if (redirectUrl.startsWith("/") && !redirectUrl.startsWith("//")) {
return true;
}

try {
const redirect = new URL(redirectUrl);
const publicUrl = publicRequestUrl(request);
if (
isBindHostname(redirect.hostname) ||
isBindHostname(publicUrl.hostname)
) {
return false;
}
return redirect.origin === publicUrl.origin;
} catch {
return false;
}
}
112 changes: 112 additions & 0 deletions apps/chatbot/lib/public-request-url.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import {
guestReturnPath,
isSafeRedirectUrl,
publicRequestUrl,
} from "./public-request-url";

const STAGING_HOST = "chatbot-demo-staging.memory.walrus.xyz";

function bindRequest(path = "/chat/abc", headers?: HeadersInit): Request {
return new Request(`https://0.0.0.0:3000${path}`, { headers });
}

describe("publicRequestUrl", () => {
it("uses x-forwarded-host and proto when request.url is a bind address", () => {
const request = bindRequest("/chat/abc", {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
});

const publicUrl = publicRequestUrl(request);
expect(publicUrl.origin).toBe(`https://${STAGING_HOST}`);
expect(publicUrl.hostname).not.toBe("0.0.0.0");
expect(publicUrl.pathname).toBe("/chat/abc");
});

it("prefers a non-bind forwarded host over request.url", () => {
const request = new Request("http://localhost:3000/login", {
headers: {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
},
});

expect(publicRequestUrl(request).origin).toBe(`https://${STAGING_HOST}`);
});

it("falls back to Host when the URL is a bind address", () => {
const request = bindRequest("/chat/abc", {
host: STAGING_HOST,
"x-forwarded-proto": "https",
});

expect(publicRequestUrl(request).origin).toBe(`https://${STAGING_HOST}`);
});
});

describe("guestReturnPath", () => {
it("returns pathname and search as a relative path", () => {
const forwarded = {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
};

expect(guestReturnPath(bindRequest("/chat/abc", forwarded))).toBe(
"/chat/abc"
);
expect(guestReturnPath(bindRequest("/chat/abc?foo=1", forwarded))).toBe(
"/chat/abc?foo=1"
);
});

it("rejects protocol-relative pathnames and falls back to /", () => {
expect(guestReturnPath(bindRequest("//evil.example"))).toBe("/");
});
});

describe("isSafeRedirectUrl", () => {
it("allows relative paths", () => {
const request = bindRequest("/", {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
});

expect(isSafeRedirectUrl("/", request)).toBe(true);
expect(isSafeRedirectUrl("/chat/1", request)).toBe(true);
});

it("rejects cross-origin and protocol-relative targets", () => {
const request = bindRequest("/chat/abc", {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
});

expect(isSafeRedirectUrl("https://evil.example", request)).toBe(false);
expect(isSafeRedirectUrl("//evil.example", request)).toBe(false);
});

it("allows same-origin absolute URLs against the public origin", () => {
const request = bindRequest("/chat/abc", {
"x-forwarded-host": STAGING_HOST,
"x-forwarded-proto": "https",
});

expect(
isSafeRedirectUrl(`https://${STAGING_HOST}/chat/abc`, request)
).toBe(true);
expect(isSafeRedirectUrl("https://0.0.0.0:3000/chat/abc", request)).toBe(
false
);
});

it("does not treat a bind address as a safe absolute redirect target", () => {
const request = bindRequest("/chat/abc");

expect(isSafeRedirectUrl("https://0.0.0.0:3000/chat/abc", request)).toBe(
false
);
expect(isSafeRedirectUrl("https://0.0.0.0:3000/", request)).toBe(false);
expect(isSafeRedirectUrl("/", request)).toBe(true);
});
});
10 changes: 7 additions & 3 deletions apps/chatbot/proxy.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type NextRequest, NextResponse } from "next/server";
import { guestRegex } from "./lib/constants";
import { guestReturnPath, publicRequestUrl } from "./lib/public-request-url";
import { getSessionToken } from "./lib/session-token";

export async function proxy(request: NextRequest) {
Expand All @@ -20,17 +21,20 @@ export async function proxy(request: NextRequest) {
const token = await getSessionToken(request);

if (!token) {
const redirectUrl = encodeURIComponent(request.url);
const redirectUrl = encodeURIComponent(guestReturnPath(request));

return NextResponse.redirect(
new URL(`/api/auth/guest?redirectUrl=${redirectUrl}`, request.url)
new URL(
`/api/auth/guest?redirectUrl=${redirectUrl}`,
publicRequestUrl(request)
)
);
}

const isGuest = guestRegex.test(token?.email ?? "");

if (token && !isGuest && ["/login", "/register"].includes(pathname)) {
return NextResponse.redirect(new URL("/", request.url));
return NextResponse.redirect(new URL("/", publicRequestUrl(request)));
}

return NextResponse.next();
Expand Down
Loading
Loading