Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
6433369
feat(noter): add server-issued challenge nonce for wallet auth replay…
Ashwin-3cS Mar 26, 2026
23b2a05
fix(noter): address review feedback on wallet challenge flow
Ashwin-3cS Mar 27, 2026
0bf8c48
chore: merge dev into feat/noter-wallet-challenge-nonce-v2, regenerat…
Ashwin-3cS Apr 2, 2026
757415b
chore(noter): merge dev into feat/noter-wallet-challenge-nonce-v2
Ashwin-3cS Apr 25, 2026
9fce7c1
fix(openclaw): clamp memory_search relevance when cosine distance > 1…
ducnmm Aug 28, 2026
ba4e336
feat(sdk): listNamespaces() so an agent can discover namespaces
nikola0x0 Aug 28, 2026
a9412a1
chore(changeset): add changeset for listNamespaces()
nikola0x0 Aug 29, 2026
71c2764
fix(chatbot): return 404 when deleting a nonexistent document (WALM-422)
HoangDucBach Aug 29, 2026
4bca853
fix(noter): bind getSession and logout to the caller's own session (#…
HoangDucBach Aug 29, 2026
48ef976
fix(researcher): check the destination before fetching a source file …
HoangDucBach Aug 29, 2026
aaab599
fix(researcher): validate AUTH_SECRET before signing or verifying a s…
HoangDucBach Aug 29, 2026
ee01813
fix(chatbot): treat a malformed Bearer header as no session (WALM-416)
HoangDucBach Sep 3, 2026
34d5cd1
docs: fix style-guide nits blocking staging promotion (WALM-460) (#853)
ducnmm Sep 4, 2026
c7f6261
fix(chatbot): scope getSuggestions to the calling user (WALM-438) (#824)
ducnmm Sep 7, 2026
692b5c7
feat(mcp): expose maxDistance on memwal_recall (WALM-457) (#850)
ducnmm Sep 7, 2026
f8d729c
Merge branch 'dev' into henrynguyen/walm-441-bug-memory_search-tool-c…
harrymove-ctrl Sep 7, 2026
52017fa
Merge pull request #826 from MystenLabs/henrynguyen/walm-441-bug-memo…
harrymove-ctrl Sep 7, 2026
117d45e
fix(researcher): unwrap SIIT and IPv4-compatible embeddings in SSRF d…
HoangDucBach Sep 7, 2026
04257b3
fix(researcher): map a malformed redirect Location to ChatbotError
HoangDucBach Sep 7, 2026
71dad62
Merge pull request #833 from MystenLabs/wyner/fix-researcher-file-url…
HoangDucBach Sep 7, 2026
e0e63a4
docs(noter): reword logout JSDoc to the header-only invariant
HoangDucBach Sep 7, 2026
85ce261
test(noter): cover createContext UUID guard on x-session-id
HoangDucBach Sep 7, 2026
622b1b0
Merge pull request #832 from MystenLabs/wyner/fix-noter-session-binding
HoangDucBach Sep 7, 2026
60063e8
docs(chatbot): drop incident recap from getSessionToken
HoangDucBach Sep 7, 2026
5966c31
Merge pull request #854 from MystenLabs/wyner/walm-416-bug-malformed-…
HoangDucBach Sep 7, 2026
eb3bc18
docs: document namespace 255-byte cap and restore truncated (WALM-486…
ducnmm Sep 7, 2026
4142d1a
ci: add production-safe SEAL cross-account synthetic (COMG-715) (#846)
ducnmm Sep 7, 2026
9300493
feat(server): expose writes ok|paused on /health (COMG-717) (#843)
ducnmm Sep 7, 2026
3683d6c
fix(auth): don't treat Sui RPC 429 as a revoked delegate key (WALM-42…
ducnmm Sep 7, 2026
f408859
fix(researcher): fail loud when AUTH_SECRET is missing during verify
HoangDucBach Sep 7, 2026
0a6755d
Merge pull request #834 from MystenLabs/wyner/fix-researcher-auth-sec…
HoangDucBach Sep 7, 2026
450c35a
Merge remote-tracking branch 'origin/dev' into nikolale/walm-395-no-w…
nikola0x0 Sep 7, 2026
d64975f
fix(python-sdk): warn on plaintext remote server_url (WALM-452) (#836)
ducnmm Sep 7, 2026
bb465a9
fix(sdk): address namespace discovery review feedback
nikola0x0 Sep 7, 2026
a9a7604
docs(chatbot): drop bug-history comment on DELETE document 404
HoangDucBach Sep 7, 2026
e6d0a43
Merge pull request #831 from MystenLabs/wyner/walm-422-bug-deleting-a…
HoangDucBach Sep 7, 2026
b11f0c0
fix(chatbot): restore chat, titles and artifacts after upstream model…
HoangDucBach Sep 7, 2026
bf8894a
fix(analyze): skip the pre-extraction embed on inputs the API will re…
nikola0x0 Aug 28, 2026
455d496
fix(analyze): align pre-extraction skip with embedder byte cap
nikola0x0 Sep 7, 2026
c9dd605
Merge pull request #830 from MystenLabs/nikolale/walm-395-no-way-to-d…
nikola0x0 Sep 7, 2026
6b2bd93
Merge pull request #828 from MystenLabs/nikolale/walm-411-feature-ski…
nikola0x0 Sep 7, 2026
11f86c4
chore(noter): resolve merge conflicts with dev and align wallet chall…
hien-p Sep 7, 2026
3da15d3
Merge pull request #53 from Ashwin-3cS/feat/noter-wallet-challenge-no…
harrymove-ctrl Sep 7, 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
57 changes: 57 additions & 0 deletions .github/workflows/check-model-ids.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Check model ids

# Verifies the OpenRouter ids apps/chatbot can send still exist upstream.
#
# Its own workflow rather than a job in test.yml because the failure it catches
# arrives with no commit attached: OpenRouter retires ids on its own timetable,
# which is how a retired title model reached production unnoticed. test.yml has
# no schedule trigger, so a weekly job there would mean guarding every other job
# in the file against a scheduled run.
#
# The catalog endpoint is public, so this needs no key and no environment.

on:
pull_request:
# Only when the curated list or the check itself moves. Every other PR would
# be taking a network dependency on OpenRouter for nothing.
paths:
- "apps/chatbot/lib/ai/models.ts"
- "apps/chatbot/scripts/check-model-ids.ts"
- ".github/workflows/check-model-ids.yml"
schedule:
# Reads nothing shared, so it does not need the bench-account offsets the
# other weekly suites coordinate around.
- cron: "0 8 * * 1"
workflow_dispatch:

concurrency:
group: check-model-ids-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

jobs:
model-ids:
name: Model ids live on OpenRouter
runs-on: ubuntu-latest
timeout-minutes: 5

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

- name: Check curated model ids against the live catalog
run: pnpm --filter @memwal/chatbot check:models
59 changes: 59 additions & 0 deletions .github/workflows/synthetic-seal-cross-account.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Synthetic SEAL cross-account

# Production-safe negative synthetic (COMG-715).
# workflow_dispatch only — never on pull_request. Missing secrets skip (exit 0).
# A live cross-account SEAL authorization fails the job with
# SYNTHETIC_SEAL_CROSS_ACCOUNT_FAIL and should page on-call.
#
# Schedule from ops (not enabled here on purpose):
# gh workflow run synthetic-seal-cross-account.yml --ref <protected-branch>
# or add `on.schedule` after the two-account secrets exist.

on:
workflow_dispatch:
inputs:
relayer_url:
description: Relayer base URL for GET /config when SUI_RPC_URL / MEMWAL_PACKAGE_ID are unset
required: false
type: string
default: ""

permissions:
contents: read

jobs:
synthetic:
name: Cross-account SEAL deny
runs-on: ubuntu-latest
timeout-minutes: 10
env:
SEAL_CROSS_ACCOUNT_A_ID: ${{ secrets.SEAL_CROSS_ACCOUNT_A_ID }}
SEAL_CROSS_ACCOUNT_B_ID: ${{ secrets.SEAL_CROSS_ACCOUNT_B_ID }}
SEAL_CROSS_ACCOUNT_A_KEY: ${{ secrets.SEAL_CROSS_ACCOUNT_A_KEY }}
SEAL_CROSS_ACCOUNT_B_KEY: ${{ secrets.SEAL_CROSS_ACCOUNT_B_KEY }}
SUI_RPC_URL: ${{ vars.SUI_RPC_URL }}
SUI_NETWORK: ${{ vars.SUI_NETWORK }}
MEMWAL_PACKAGE_ID: ${{ vars.MEMWAL_PACKAGE_ID }}
MEMWAL_REGISTRY_ID: ${{ vars.MEMWAL_REGISTRY_ID }}
MEMWAL_SEAL_POLICY_PACKAGE_ID: ${{ vars.MEMWAL_SEAL_POLICY_PACKAGE_ID }}
MEMWAL_SERVER_URL: ${{ inputs.relayer_url || vars.MEMWAL_SERVER_URL }}
steps:
- uses: actions/checkout@v4

- name: Detect secrets
id: cfg
shell: bash
run: |
if [ -z "${SEAL_CROSS_ACCOUNT_A_ID:-}" ] || [ -z "${SEAL_CROSS_ACCOUNT_B_ID:-}" ] || \
[ -z "${SEAL_CROSS_ACCOUNT_A_KEY:-}" ] || [ -z "${SEAL_CROSS_ACCOUNT_B_KEY:-}" ]; then
echo "has_secrets=false" >> "$GITHUB_OUTPUT"
else
echo "has_secrets=true" >> "$GITHUB_OUTPUT"
fi

- name: Setup JS
if: steps.cfg.outputs.has_secrets == 'true'
uses: ./.github/actions/setup-js

- name: Run cross-account SEAL synthetic
run: node scripts/synthetic-seal-cross-account.mjs
2 changes: 2 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jobs:
script: scripts/check-docs-code-sync.mjs
- name: Docs / Freshness
script: scripts/check-docs-freshness.mjs
- name: SEAL cross-account synthetic parser
script: scripts/synthetic-seal-cross-account.test.mjs

steps:
- uses: actions/checkout@v4
Expand Down
19 changes: 15 additions & 4 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 }` |
| `restore(namespace, limit?)` | Rebuild missing index entries from Walrus | `{ restored, skipped, total, namespace, owner, truncated }` |
| `health()` | Check relayer health | `{ status, version }` |
| `getPublicKeyHex()` | Get hex-encoded public key | `string` |

Expand Down Expand Up @@ -274,6 +274,7 @@ interface RestoreResult {
total: number;
namespace: string;
owner: string;
truncated: boolean;
}

interface HealthResult {
Expand Down Expand Up @@ -315,9 +316,16 @@ A namespace is an **opaque, flat string label** scoped to a single owner. It is

#### Validation

The server accepts any non-empty string as a namespace. There is no length cap, no character whitelist, no normalization (whitespace, case, Unicode). Whatever you send is stored verbatim and matched with exact equality. If you omit the namespace, the server falls back to the literal string `"default"`.
Omit `namespace` and the server uses the literal string `"default"`. An explicit empty string is rejected with HTTP 400 (`namespace cannot be empty`).

> **Implication:** `"my-app"`, `" my-app"` (leading space), `"My-App"`, and `"my-app/"` are four distinct namespaces. Pick a convention and stick to it.
The server then accepts any non-empty UTF-8 string except:

- more than **255 bytes** (UTF-8 byte length, not character count — Rust `str::len()`) → HTTP 400 `namespace exceeds maximum length of 255 bytes`
- a NUL byte (`\0`) → HTTP 400 `namespace contains a NUL byte` (WALM-439). Tabs, newlines, and other control characters are still allowed so older namespaces stay readable.

There is no character whitelist, no case folding, no trim, and no Unicode normalization. Whatever passes validation is stored verbatim and matched with exact equality.

> **Implication:** `"my-app"`, `" my-app"` (leading space), `"My-App"`, and `"my-app/"` are four distinct namespaces. Pick a convention and stick to it. Multi-byte characters (CJK, emoji) consume more than one byte each, so they hit the 255-byte cap sooner than a character count would suggest.

#### Flat, not hierarchical

Expand Down Expand Up @@ -359,14 +367,17 @@ Cross-namespace and cross-owner reads are not just filtered out of results — t
| `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`.

#### Default and limit

* `limit` defaults to `10` in both TypeScript and Python SDKs and matches the server-side default. The Python SDK historically defaulted to `50`; it is now realigned with the server.
* `limit` caps the **inspected** blob set, newest-first. It does not cap `restored` independently — if all 10 inspected blobs are already indexed, `restored = 0` and `skipped = 10`.
* There is no enforced server-side maximum, but very large limits will dominate latency (see below).
* The relayer clamps `limit` to 1–100 (values outside that range are clamped, not rejected).

#### Pagination

Expand Down
9 changes: 2 additions & 7 deletions apps/chatbot/app/(auth)/api/auth/guest/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import { signIn } from "@/app/(auth)/auth";
import { isDevelopmentEnvironment } from "@/lib/constants";
import { getSessionToken } from "@/lib/session-token";

/**
* Validate a redirect target before forwarding to auth.
Expand Down Expand Up @@ -35,11 +34,7 @@ export async function GET(request: Request) {
? rawRedirectUrl
: "/";

const token = await getToken({
req: request,
secret: process.env.AUTH_SECRET,
secureCookie: !isDevelopmentEnvironment,
});
const token = await getSessionToken(request);

if (token) {
return NextResponse.redirect(new URL("/", request.url));
Expand Down
2 changes: 2 additions & 0 deletions apps/chatbot/app/(chat)/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { generateText, type UIMessage } from "ai";
import { cookies } from "next/headers";
import type { VisibilityType } from "@/components/visibility-selector";
import { MAX_TITLE_OUTPUT_TOKENS } from "@/lib/ai/models";
import { titlePrompt } from "@/lib/ai/prompts";
import { getTitleModel } from "@/lib/ai/providers";
import {
Expand Down Expand Up @@ -35,6 +36,7 @@ export async function generateTitleFromUserMessage({
model: getTitleModel(),
system: titlePrompt,
prompt: getTextFromMessage(message),
maxOutputTokens: MAX_TITLE_OUTPUT_TOKENS,
});
return text
.replace(/^[#*"\s]+/, "")
Expand Down
15 changes: 10 additions & 5 deletions apps/chatbot/app/(chat)/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import { createResumableStreamContext } from "resumable-stream";
import { auth, type UserType } from "@/app/(auth)/auth";
import { entitlementsByUserType } from "@/lib/ai/entitlements";
import { memoryNamespaceForUser } from "@/lib/ai/memory-namespace";
import { allowedModelIds } from "@/lib/ai/models";
import {
allowedModelIds,
isReasoningModelId,
MAX_OUTPUT_TOKENS,
MAX_REASONING_OUTPUT_TOKENS,
} from "@/lib/ai/models";
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
import { getLanguageModel, getMemWalModel } from "@/lib/ai/providers";
import { createDocument } from "@/lib/ai/tools/create-document";
Expand Down Expand Up @@ -177,10 +182,7 @@ export async function POST(request: Request) {
});
}

const isReasoningModel =
selectedChatModel.endsWith("-thinking") ||
(selectedChatModel.includes("reasoning") &&
!selectedChatModel.includes("non-reasoning"));
const isReasoningModel = isReasoningModelId(selectedChatModel);

const modelMessages = await convertToModelMessages(uiMessages);

Expand All @@ -197,6 +199,9 @@ export async function POST(request: Request) {
: getLanguageModel(selectedChatModel),
system: systemPrompt({ selectedChatModel, requestHints }),
messages: modelMessages,
maxOutputTokens: isReasoningModel
? MAX_REASONING_OUTPUT_TOKENS
: MAX_OUTPUT_TOKENS,
stopWhen: stepCountIs(5),
experimental_activeTools: isReasoningModel
? []
Expand Down
124 changes: 124 additions & 0 deletions apps/chatbot/app/(chat)/api/document/document-route.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// Regression test for WALM-422: DELETE /api/document read `.userId` off the
// first row without checking that a row came back, so an id matching no
// document threw TypeError and the request 500s with an empty body. The GET
// handler in the same file already had the guard, so the two disagreed on the
// same missing document.
//
// The DB query layer is mocked so no Postgres connection is needed.

const OWNER_ID = "11111111-1111-1111-1111-111111111111";
const OTHER_ID = "22222222-2222-2222-2222-222222222222";
const DOC_ID = "33333333-3333-3333-3333-333333333333";
const MISSING_ID = "44444444-4444-4444-4444-444444444444";
const TIMESTAMP = "2026-01-01T00:00:00.000Z";

function makeDoc(userId = OWNER_ID) {
return {
id: DOC_ID,
userId,
title: "A document",
content: "body",
kind: "text" as const,
createdAt: new Date(),
};
}

const getDocumentsById = vi.fn(async ({ id }: { id: string }) =>
id === DOC_ID ? [makeDoc()] : []
);
const deleteDocumentsByIdAfterTimestamp = vi.fn(async () => [makeDoc()]);
const saveDocument = vi.fn(async () => makeDoc());

vi.mock("@/lib/db/queries", () => ({
getDocumentsById,
deleteDocumentsByIdAfterTimestamp,
saveDocument,
}));

const auth = vi.fn(async () => ({ user: { id: OWNER_ID }, expires: "" }));
vi.mock("@/app/(auth)/auth", () => ({ auth }));

function deleteRequest(id: string, timestamp = TIMESTAMP) {
return new Request(
`http://localhost/api/document?id=${id}&timestamp=${timestamp}`,
{ method: "DELETE" }
);
}

async function callDelete(id: string) {
const { DELETE } = await import("./route");
return DELETE(deleteRequest(id));
}

beforeEach(() => {
vi.clearAllMocks();
getDocumentsById.mockImplementation(async ({ id }: { id: string }) =>
id === DOC_ID ? [makeDoc()] : []
);
auth.mockResolvedValue({ user: { id: OWNER_ID }, expires: "" });
});

describe("DELETE /api/document", () => {
it("returns 404 for an id that matches no document", async () => {
const response = await callDelete(MISSING_ID);

expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
code: "not_found:document",
});
// Nothing may be deleted on the way to reporting the miss.
expect(deleteDocumentsByIdAfterTimestamp).not.toHaveBeenCalled();
});

it("agrees with GET on the same missing document", async () => {
const { GET } = await import("./route");
const getResponse = await GET(
new Request(`http://localhost/api/document?id=${MISSING_ID}`)
);
const deleteResponse = await callDelete(MISSING_ID);

expect(deleteResponse.status).toBe(getResponse.status);
});

it("returns 403 for a document owned by someone else", async () => {
getDocumentsById.mockResolvedValue([makeDoc(OTHER_ID)]);

const response = await callDelete(DOC_ID);

expect(response.status).toBe(403);
expect(deleteDocumentsByIdAfterTimestamp).not.toHaveBeenCalled();
});

it("deletes the caller's own document", async () => {
const response = await callDelete(DOC_ID);

expect(response.status).toBe(200);
expect(deleteDocumentsByIdAfterTimestamp).toHaveBeenCalledWith({
id: DOC_ID,
timestamp: new Date(TIMESTAMP),
});
});

it("rejects a missing timestamp before reaching the database", async () => {
const { DELETE } = await import("./route");
const response = await DELETE(
new Request(`http://localhost/api/document?id=${DOC_ID}`, {
method: "DELETE",
})
);

expect(response.status).toBe(400);
expect(getDocumentsById).not.toHaveBeenCalled();
});

it("rejects an unauthenticated caller before reaching the database", async () => {
auth.mockResolvedValue(null as never);

const response = await callDelete(DOC_ID);

expect(response.status).toBe(401);
expect(getDocumentsById).not.toHaveBeenCalled();
});
});
4 changes: 4 additions & 0 deletions apps/chatbot/app/(chat)/api/document/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ export async function DELETE(request: Request) {

const [document] = documents;

if (!document) {
return new ChatbotError("not_found:document").toResponse();
}

if (document.userId !== session.user.id) {
return new ChatbotError("forbidden:document").toResponse();
}
Expand Down
Loading
Loading