diff --git a/.changeset/gh-571-clock-drift-error.md b/.changeset/gh-571-clock-drift-error.md deleted file mode 100644 index 15a9f5738..000000000 --- a/.changeset/gh-571-clock-drift-error.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@mysten-incubation/memwal": patch ---- - -Surface relayer clock-drift rejections as an actionable error (#571). - -When the relayer rejects a signed request because the client's timestamp is outside its accepted clock-drift window, it now returns `401` with an `x-auth-error: ERR_TIMESTAMP_OUT_OF_BOUNDS` header. The SDK detects this on both the Relayer and manual request paths and throws a clear error (`serverCode: "ERR_TIMESTAMP_OUT_OF_BOUNDS"`) telling the caller to synchronize the client clock — instead of an opaque `401`. Fully backward-compatible: responses without the header behave exactly as before. diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 000000000..1a2d78b7b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,109 @@ +name: Bug report +description: Something is broken. We need a repro, environment, and versions. +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Incomplete issues (no repro, no environment) are closed. + + Do not paste delegate private keys, mnemonics, or other secrets. + + If this is a security finding, use a [private advisory](https://github.com/MystenLabs/MemWal/security/advisories/new) instead of this form. + + - type: dropdown + id: surface + attributes: + label: Surface + description: Where did you hit this? + options: + - TypeScript SDK (@mysten-incubation/memwal) + - Python SDK (memwal) + - MCP (@mysten-incubation/memwal-mcp) + - Relayer / hosted API + - Dashboard / Console (memory.walrus.xyz) + - Developer Playground + - OpenClaw plugin + - Example app (chatbot, noter, researcher) + - Docs + - Other + validations: + required: true + + - type: dropdown + id: network + attributes: + label: Network + options: + - Mainnet (relayer.memory.walrus.xyz) + - Staging (relayer-staging.memory.walrus.xyz) + - Local / self-hosted + - Not sure + validations: + required: true + + - type: input + id: version + attributes: + label: Package version + description: The package or binary version you actually ran, not "latest". + placeholder: "@mysten-incubation/memwal@0.0.x or memwal-mcp@0.0.x" + validations: + required: true + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: One or two sentences. What did you do, and what broke? + placeholder: memwal_analyze reported 3 blob_ids but restore shows 6 blobs in a fresh namespace. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Commands, tool calls, or UI clicks another person can follow. + placeholder: | + 1. Create a fresh namespace. + 2. Call memwal_analyze with text X. + 3. Call memwal_restore / memwal_recall on that namespace. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected + placeholder: Analyze writes N blobs and reports N blob_ids. Recall returns each fact once. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual + placeholder: Tool reports 3 blob_ids. restore total=6. recall returns each fact twice. + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs or error text + description: Paste the error or tool response. Redact keys. Leave blank if none. + render: shell + validations: + required: false + + - type: checkboxes + id: confirm + attributes: + label: Checks + options: + - label: I searched existing issues and this is not a duplicate. + required: true + - label: This report contains no private keys, mnemonics, or other secrets. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..a08bdb25e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Docs and troubleshooting + url: https://docs.wal.app/walrus-memory/troubleshooting/overview + about: 401s, login, empty recall, and MCP setup. Check here before filing a bug. + - name: Search existing issues + url: https://github.com/MystenLabs/MemWal/issues?q=is%3Aissue + about: Many requests (list, forget, timestamps, pagination) already have an issue. + - name: Walrus Discord + url: https://discord.gg/walrusprotocol + about: Questions and builder support that are not a bug or a feature request. + - name: Report a security vulnerability + url: https://github.com/MystenLabs/MemWal/security/advisories/new + about: Private advisory. Do not file a public issue for security findings. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 000000000..78e101ab5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,63 @@ +name: Feature request +description: Ask for a new capability. Search existing issues first. Duplicates are closed. +title: "[Feature] " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Feature requests without a concrete problem are closed. + + Search existing issues first. Requests for namespace listing, forget/delete, timestamps, and recall pagination already have threads. Comment there instead of opening a new issue. + + - type: dropdown + id: surface + attributes: + label: Surface + options: + - TypeScript SDK (@mysten-incubation/memwal) + - Python SDK (memwal) + - MCP (@mysten-incubation/memwal-mcp) + - Relayer / hosted API + - Dashboard / Console (memory.walrus.xyz) + - Developer Playground + - OpenClaw plugin + - Docs + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem + description: What can you not do today? A real workload beats a wishlist. + placeholder: After an agent reset, stale memories keep winning recall. There is no way to stop a fact from being recalled. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What would you like? + description: The smallest change that would unblock you. + placeholder: An owner-scoped forget(memory_id) that stops the fact from appearing in recall. + validations: + required: true + + - type: textarea + id: workaround + attributes: + label: Workaround today + description: What you do instead, if anything. Optional. + placeholder: Rotate the namespace prefix and abandon the old one. + validations: + required: false + + - type: checkboxes + id: confirm + attributes: + label: Checks + options: + - label: I searched existing issues and this is not a duplicate. + required: true diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml new file mode 100644 index 000000000..3e3c84e37 --- /dev/null +++ b/.github/workflows/test-sdk.yml @@ -0,0 +1,187 @@ +name: Test JS SDK + +on: + pull_request: + paths: + - 'packages/sdk/**' + - '.github/workflows/test-sdk.yml' + push: + branches: + - main + - staging + - dev + paths: + - 'packages/sdk/**' + - '.github/workflows/test-sdk.yml' + workflow_dispatch: + inputs: + target_environment: + description: Benchmark environment to run the authenticated e2e suite against + required: true + type: choice + default: dev + options: + - dev + - staging + - production + schedule: + # Catches relayer drift with no code change to trigger it. Offset 30 + # minutes from test-python-sdk.yml (17 9 * * 1) so the two weekly suites + # don't write through the shared bench account at the same time. + - cron: '47 9 * * 1' + +concurrency: + group: test-sdk-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + unit: + name: Unit / Node ${{ matrix.node-version }} + runs-on: ubuntu-latest + timeout-minutes: 10 + + strategy: + fail-fast: false + matrix: + # 22 is what the rest of CI runs on; 24 is the active LTS. + node-version: ['22', '24'] + + 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: ${{ matrix.node-version }} + cache: pnpm + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Typecheck, build and unit tests + run: pnpm --filter @mysten-incubation/memwal test + + e2e: + name: E2E / ${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} relayer + runs-on: ubuntu-latest + needs: unit + timeout-minutes: 30 + + # Each long-lived branch tests the deployment it corresponds to. Pull + # requests are excluded because environment secrets are withheld from fork + # PRs, and every authenticated run writes real memories. + if: >- + github.event_name == 'workflow_dispatch' || + github.event_name == 'schedule' || + (github.event_name == 'push' && + (github.ref_name == 'dev' || + github.ref_name == 'staging' || + github.ref_name == 'main')) + + # Reuses the credentials benchmark-live.yml and test-python-sdk.yml already + # rely on, so this needs no new secrets. Safe to share: the benchmark + # writes to the `benchmark` namespace while these tests use a per-run + # `sdk-e2e-`. + # + # main maps to benchmark-production, so a push to main writes real + # memories through the live Walrus pipeline on the production benchmark + # account. That is intentional, and the per-run namespace is what keeps it + # contained — never point this job at an account holding user data, and + # never relax the namespace isolation in live.e2e.mjs. + environment: + name: benchmark-${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} + + env: + # Which deployment this run targets. Mirrors the `environment:` name + # above so error messages and the run summary can name it. + ENVIRONMENT_NAME: benchmark-${{ github.event_name == 'workflow_dispatch' && inputs.target_environment || (github.ref_name == 'main' && 'production' || github.ref_name == 'staging' && 'staging' || 'dev') }} + # BENCH_DELEGATE_KEY is the delegate private key the SDK signs with, + # which test/e2e/live.e2e.mjs reads as MEMWAL_PRIVATE_KEY. + MEMWAL_PRIVATE_KEY: ${{ secrets.BENCH_DELEGATE_KEY }} + MEMWAL_ACCOUNT_ID: ${{ secrets.BENCH_ACCOUNT_ID }} + # Public URL, set per environment (docs/relayer/benchmark-ci-setup.md). + # Deliberately NOT defaulted: with three environments in play, a literal + # fallback would silently run the production job against whichever + # relayer the default names. A missing variable fails the job instead. + MEMWAL_SERVER_URL: ${{ vars.BENCH_SERVER_URL }} + + 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: Build + run: pnpm --filter @mysten-incubation/memwal build + + - name: Check credentials + id: config + shell: bash + run: | + set -euo pipefail + + # Without the credentials the suite skips every authenticated test, + # and the job would report success having never exercised the + # relayer beyond /health. Without the URL there is no safe guess to + # fall back on. Fail instead of reporting a green run that proved + # nothing, or running the wrong deployment. + missing=0 + for name in MEMWAL_PRIVATE_KEY MEMWAL_ACCOUNT_ID MEMWAL_SERVER_URL; do + if [ -z "${!name:-}" ]; then + echo "::error::${name} is empty — set BENCH_DELEGATE_KEY / BENCH_ACCOUNT_ID / BENCH_SERVER_URL on the ${ENVIRONMENT_NAME} environment." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + exit 1 + fi + echo "authenticated=true" >> "$GITHUB_OUTPUT" + + - name: E2E tests + id: e2e + working-directory: packages/sdk + run: | + node --test \ + --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=e2e-results.xml \ + "test/e2e/live.e2e.mjs" + + - name: Write run summary + if: always() + run: | + { + echo "## JS SDK e2e" + echo + echo "| Field | Value |" + echo "| --- | --- |" + echo "| Environment | \`${ENVIRONMENT_NAME}\` |" + echo "| Relayer | \`${MEMWAL_SERVER_URL:-}\` |" + echo "| Authenticated | ${{ steps.config.outputs.authenticated }} |" + echo "| Result | ${{ steps.e2e.outcome }} |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload e2e results + if: always() + uses: actions/upload-artifact@v4 + with: + name: js-sdk-e2e-${{ env.ENVIRONMENT_NAME }}-${{ github.run_id }} + path: packages/sdk/e2e-results.xml + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ee6be4177..f385926aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,94 @@ jobs: retention-days: 14 if-no-files-found: ignore + noter-e2e: + name: Noter / Playwright E2E + runs-on: ubuntu-latest + timeout-minutes: 25 + + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: noter + POSTGRES_PASSWORD: noter_secret + POSTGRES_DB: noter + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U noter" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://noter:noter_secret@localhost:5432/noter + NEXT_PUBLIC_APP_URL: http://localhost:3002 + PORT: "3002" + NODE_ENV: test + PLAYWRIGHT: "True" + # NEXT_PUBLIC_* Enoki/Sui vars are inlined at build time — placeholders + # are fine here since the e2e suite authenticates via delegate key, not + # the Google/Enoki popup flow (that needs a real OAuth session and stays + # a manual check, same as researcher's live-Walrus canary in #680). + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-not-used-tests-use-delegate-key + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + # No live-Walrus canary in this suite: isTestEnvironment flips the + # binding check onto the fixture pool, so even a real on-chain key + # would fail at login. CI covers auth + note CRUD + the memory API + # contract; remember → recall against production stays a manual check. + + 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: Build SDK (workspace dep of noter) + run: pnpm build:sdk + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + # Separate from chatbot's `pw-` key so the two jobs don't race the + # same cache entry. Fall back to the shared prefix on a cold start. + key: pw-noter-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pw-noter-${{ runner.os }}- + pw-${{ runner.os }}- + + - name: Install Playwright (Chromium + OS deps) + timeout-minutes: 8 + run: pnpm --filter @memwal/noter playwright:install + + - name: Run Playwright E2E + run: pnpm --filter @memwal/noter test:e2e + + - name: Upload Playwright report + traces + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report-noter + path: | + apps/noter/playwright-report + apps/noter/test-results + retention-days: 14 + if-no-files-found: ignore + server-e2e: name: Server / E2E runs-on: ubuntu-latest @@ -364,10 +452,20 @@ jobs: run: pnpm exec next build noter-checks: - name: Noter / Unit tests + name: Noter / Unit tests + Build runs-on: ubuntu-latest timeout-minutes: 20 + env: + # Dummy DB — next build type-checks route handlers but doesn't connect. + DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy + NEXT_PUBLIC_ENOKI_API_KEY: ci-placeholder-build-only + NEXT_PUBLIC_GOOGLE_CLIENT_ID: ci-placeholder-build-only + NEXT_PUBLIC_SUI_NETWORK: testnet + NEXT_PUBLIC_MEMWAL_PACKAGE_ID: "0xcf6ad755a1cdff7217865c796778fabe5aa399cb0cf2eba986f4b582047229c6" + NEXT_PUBLIC_MEMWAL_REGISTRY_ID: "0xe80f2feec1c139616a86c9f71210152e2a7ca552b20841f2e192f99f75864437" + NEXT_PUBLIC_MEMWAL_SERVER_URL: https://relayer.dev.memwal.ai + steps: - name: Checkout uses: actions/checkout@v4 @@ -393,6 +491,14 @@ jobs: - name: Unit tests (vitest) run: pnpm --filter @memwal/noter test:unit + - name: Type-check (tsc --noEmit) + working-directory: apps/noter + run: pnpm exec tsc --noEmit + + - name: Build (Next.js, type-check inclusive) + working-directory: apps/noter + run: pnpm exec next build + server-checks: name: Server / Clippy + Unit tests runs-on: ubuntu-latest diff --git a/apps/app/src/index.css b/apps/app/src/index.css index ea322ec40..d29c499be 100644 --- a/apps/app/src/index.css +++ b/apps/app/src/index.css @@ -11930,3 +11930,58 @@ h1, h2, h3 { padding: 20px; } } + +.dash-page .dash-alert--info { + min-height: 0; + align-items: flex-start; + gap: 12px; + margin-bottom: 28px !important; + padding: 14px 18px; + border: 1px solid var(--dash-panel-border); + border-radius: var(--radius-md); + background: var(--dash-panel); + color: var(--dash-subtle); +} + +.dash-page .dash-alert--info .dash-alert-icon { + width: 20px; + height: 20px; + margin-top: 2px; + color: var(--dash-yellow); +} + +.dash-page .dash-alert--info p { + color: var(--dash-subtle); + font-size: 15px; + font-weight: 400; + line-height: 1.5; +} + +.dash-page .dash-alert-link { + padding: 0; + border: 0; + background: none; + color: var(--dash-yellow); + font: inherit; + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.dash-page .dash-alert-link:hover { + text-decoration-thickness: 2px; +} + +.dash-page .dashboard-key-current-badge { + white-space: nowrap; +} + +@media (max-width: 640px) { + .dash-page .dash-alert--info { + padding: 12px 14px; + } + + .dash-page .dash-alert--info p { + font-size: 14px; + } +} diff --git a/apps/app/src/pages/Dashboard.tsx b/apps/app/src/pages/Dashboard.tsx index dc2968b7c..c8af1956f 100644 --- a/apps/app/src/pages/Dashboard.tsx +++ b/apps/app/src/pages/Dashboard.tsx @@ -14,7 +14,7 @@ import { useSponsoredTransaction } from '../hooks/useSponsoredTransaction' import { generateDelegateKey } from '@mysten-incubation/memwal/account' import type { WalletSigner } from '@mysten-incubation/memwal/manual' import { Link, useNavigate } from 'react-router-dom' -import { TriangleAlert, Copy, Eye, EyeOff, Trash2, RefreshCw, Plus, LogOut, Github, MessageCircle } from 'lucide-react' +import { TriangleAlert, Info, Copy, Eye, EyeOff, Trash2, RefreshCw, Plus, LogOut, Github, MessageCircle } from 'lucide-react' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import js from 'react-syntax-highlighter/dist/esm/languages/hljs/javascript' import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python' @@ -118,6 +118,7 @@ interface OnChainDelegateKey { const MAX_DELEGATE_KEYS = 20 const MAX_DELEGATE_KEYS_MESSAGE = 'This wallet already has 20 delegate keys. Remove an old key before creating a new delegate key.' +const DELEGATE_KEYS_SECTION_ID = 'delegate-keys' const PRIVATE_KEY_ENV = 'MEMWAL_PRIVATE_KEY' const ACCOUNT_ID_ENV = 'MEMWAL_ACCOUNT_ID' const SERVER_URL_ENV = 'MEMWAL_SERVER_URL' @@ -368,7 +369,6 @@ export default function Dashboard({ const hasResolvedAccount = Boolean(effectiveAccountObjectId) const accountLookupPending = loadingAccount || (shouldResolveAccount && (!accountLookupComplete || accountLookupAddress !== address)) - const isRecoveringExistingAccount = !delegateKey && hasResolvedAccount && !previewReady const activeEnvironmentLabel = config.suiNetwork === 'mainnet' ? 'production / mainnet' : 'staging / testnet' @@ -401,12 +401,21 @@ export default function Dashboard({ const hasMaxDelegateKeys = onChainKeys.length >= MAX_DELEGATE_KEYS const isKeyListLoading = accountLookupPending || (loadingKeys && onChainKeys.length === 0) const isKeyListRefreshing = loadingKeys && onChainKeys.length > 0 + const onChainKeyCount = onChainKeys.length + const browserHasNoKey = !delegateKey && hasResolvedAccount && !previewReady + const showNoBrowserKeyNotice = browserHasNoKey && !isKeyListLoading const selectableKeyPublicKeys = useMemo(() => onChainKeys.map((key) => key.publicKey), [onChainKeys]) const selectedKeySet = useMemo(() => new Set(selectedKeyPublicKeys), [selectedKeyPublicKeys]) const selectedKeyCount = selectedKeyPublicKeys.length const keyRemovalBusy = removingSelectedKeys || Boolean(removingKey) const showKeySelectionControls = Boolean(effectiveAccountObjectId) && selectedKeyCount > 0 && !accountLookupPending + const scrollToDelegateKeys = useCallback(() => { + document + .getElementById(DELEGATE_KEYS_SECTION_ID) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, []) + useEffect(() => { setSelectedKeyPublicKeys((prev) => { const next = prev.filter((publicKey) => selectableKeyPublicKeys.includes(publicKey)) @@ -775,12 +784,30 @@ const result = await generateText({ {showDashboardSubtitle &&

{dashboardSubtitle}

} - {isRecoveringExistingAccount && ( -
-