From 10d4bf7a48fd5ab0ce6fc67caa407a717f81830e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:49:42 +0800 Subject: [PATCH 01/31] chore(migration): prepare ByteFolk GitHub coordinates (#142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - move GitHub repository, issue, badge, contribution, Release, and raw-content coordinates to `bytefolk` - update the npm bootstrapper default Release repository and its regression coverage - preserve `@fullstack-ai-infra/mem-mcp`, `io.github.fullstack-ai-infra/mem-mcp`, and existing cache paths ## Cutover gate This PR prepares the repository for the organization handle cutover tracked by https://github.com/fullstack-ai-infra/.github/issues/20. Do not merge before the organization rename window. ## Validation - `cd npm && npm test` — 34 passed, 1 Windows-only test skipped on macOS - `bash scripts/validate_release_version.sh 0.1.1` — passed - `git diff --check` — passed - old GitHub URL/API/raw/SSH owner-coordinate scan — 0 matches - stable npm scope, MCP identity, and cache paths — retained ## Rollback Before cutover, revert this commit if the rename window is cancelled. After cutover, GitHub redirects provide a safety net, while the canonical coordinates in this PR should remain. Refs fullstack-ai-infra/.github#20 --- AGENTS.md | 2 +- CHANGELOG.md | 14 ++++++++++---- README.md | 8 ++++---- SECURITY.md | 6 +++--- deploy/helm/mem/values.yaml | 2 +- docs/DEPLOYMENT.md | 6 +++--- docs/DEVELOPMENT.md | 2 +- docs/DURABLE_CONTEXT.md | 2 +- docs/integrations/qoder-ingest.md | 6 +++--- npm/README.md | 6 +++--- npm/install.js | 2 +- npm/install.test.js | 5 ++--- npm/mem-mcp | 2 +- npm/package.json | 6 +++--- npm/server.json | 2 +- npm/windows-shim.test.js | 2 +- scripts/validate_release_version.sh | 6 +++--- 17 files changed, 42 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 340befe..7144859 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Repository Instructions for Agents These instructions apply to the entire repository. They supplement the public -[organization contribution rules](https://github.com/fullstack-ai-infra/.github/blob/main/CONTRIBUTING.md) +[organization contribution rules](https://github.com/bytefolk/.github/blob/main/CONTRIBUTING.md) and the `mem`-specific contracts in `docs/DEVELOPMENT.md`. ## Required workflow diff --git a/CHANGELOG.md b/CHANGELOG.md index 66042e5..583287f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ## [Unreleased] +### Changed + +- Migrate GitHub repository, Release, issue, badge, and raw-content coordinates + to the canonical `bytefolk` organization while retaining the published npm + scope, MCP identity, and existing cache paths. + ### Security - Normalize the client-declared MIME type of a stored file before deciding how @@ -258,7 +264,7 @@ The project publishes 0.x prerelease versions; a stable release line is not yet top-level paths remain hidden compatibility aliases with deprecation warnings. - Inherit organization-wide contribution, issue, pull-request, conduct, and - support defaults from `fullstack-ai-infra/.github`; keep only `mem`-specific + support defaults from `bytefolk/.github`; keep only `mem`-specific development, security, triage, ownership, release, and validation rules in this repository. - Align pull-request policy with the inherited controlled exceptions for @@ -349,6 +355,6 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - Preserve the primary Web acceptance failure when browser or Vite cleanup also fails. -[Unreleased]: https://github.com/fullstack-ai-infra/mem/compare/v0.1.1...HEAD -[0.1.1]: https://github.com/fullstack-ai-infra/mem/compare/v0.1.0...v0.1.1 -[0.1.0]: https://github.com/fullstack-ai-infra/mem/releases/tag/v0.1.0 +[Unreleased]: https://github.com/bytefolk/mem/compare/v0.1.1...HEAD +[0.1.1]: https://github.com/bytefolk/mem/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/bytefolk/mem/releases/tag/v0.1.0 diff --git a/README.md b/README.md index aabe0ce..8b333d6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > > 开源 · 自托管 · 模型可插拔 · API / MCP / CLI / UI 共用一套记忆内核。 -[![CI](https://github.com/fullstack-ai-infra/mem/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fullstack-ai-infra/mem/actions/workflows/ci.yml) +[![CI](https://github.com/bytefolk/mem/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/bytefolk/mem/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) [![Status](https://img.shields.io/badge/status-experimental-orange.svg)](#project-status) [![MCP Server](https://img.shields.io/badge/MCP%20Server-26%20tools-blue?logo=modelcontextprotocol)](docs/mcp.md) @@ -233,7 +233,7 @@ mem 的壁垒不是绑定某个更大的模型,而是长期积累的、用户 项目仍处于 Phase 1 MVP。当前开发体验: ```bash -git clone https://github.com/fullstack-ai-infra/mem.git +git clone https://github.com/bytefolk/mem.git cd mem ./scripts/dev_up.sh mem auth login @@ -298,7 +298,7 @@ embedding,平台托管 embedding 则使用独立的 workspace 权益和额度 - 本地运行与验证:[docs/RUN_LOCAL.md](docs/RUN_LOCAL.md) - 项目开发边界:[docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) - 测试环境与回归门槛:[docs/TESTING.md](docs/TESTING.md) -- 参与贡献:[组织贡献基线](https://github.com/fullstack-ai-infra/.github/blob/main/CONTRIBUTING.md) +- 参与贡献:[组织贡献基线](https://github.com/bytefolk/.github/blob/main/CONTRIBUTING.md) 与 [mem 开发契约](docs/DEVELOPMENT.md) - 版本变化:[CHANGELOG.md](CHANGELOG.md) @@ -415,7 +415,7 @@ All changes follow an issue-first, pull-request-only workflow: 5. Obtain an independent review and pass required CI checks before merge. Read the -[organization contribution baseline](https://github.com/fullstack-ai-infra/.github/blob/main/CONTRIBUTING.md) +[organization contribution baseline](https://github.com/bytefolk/.github/blob/main/CONTRIBUTING.md) and the [mem-specific development contract](docs/DEVELOPMENT.md), then use [docs/maintainers/triage.md](docs/maintainers/triage.md) for the issue taxonomy. Security reports must follow [SECURITY.md](SECURITY.md), not a diff --git a/SECURITY.md b/SECURITY.md index 0ba14c3..bbf67cc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,7 @@ # Security Policy The organization-wide -[security policy](https://github.com/fullstack-ai-infra/.github/blob/main/SECURITY.md) +[security policy](https://github.com/bytefolk/.github/blob/main/SECURITY.md) defines confidential reporting, coordinated disclosure, and safe-research requirements. This file adds only the versions, scope, response target, and private intake route specific to `mem`. @@ -21,9 +21,9 @@ begin, the latest stable release will also receive security fixes. Do not disclose a suspected vulnerability in a public issue, discussion, or pull request. Use the -[`mem` private vulnerability reporting form](https://github.com/fullstack-ai-infra/mem/security/advisories/new). +[`mem` private vulnerability reporting form](https://github.com/bytefolk/mem/security/advisories/new). If the form is unavailable, follow the confidential fallback in the -organization security policy and identify `fullstack-ai-infra/mem` as the +organization security policy and identify `bytefolk/mem` as the affected repository. Maintainers aim to acknowledge a complete report within five business days. diff --git a/deploy/helm/mem/values.yaml b/deploy/helm/mem/values.yaml index c6e16db..90cdb5c 100644 --- a/deploy/helm/mem/values.yaml +++ b/deploy/helm/mem/values.yaml @@ -32,7 +32,7 @@ serviceAccount: memd: # memd coordinates indexing and embedding-provider switches in process. - # Keep one replica until https://github.com/fullstack-ai-infra/mem/issues/55 + # Keep one replica until https://github.com/bytefolk/mem/issues/55 # provides cross-replica index generations. replicaCount: 1 resources: diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index f1fcc93..83acc7a 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -8,7 +8,7 @@ only in where stateful dependencies run and which workloads can scale. > The deployment assets are suitable for private self-hosting. Do not expose a > `mem` installation as a public multi-tenant service until the hosted > authentication and abuse-control work in -> [issue #65](https://github.com/fullstack-ai-infra/mem/issues/65) is complete. +> [issue #65](https://github.com/bytefolk/mem/issues/65) is complete. > The Helm profile is the intended foundation for that service, but > horizontal scaling alone does not make the current login/session model > Internet-service grade. @@ -394,7 +394,7 @@ boundary and object store. memd must stay at one replica and uses a `Recreate` rollout so old and new pods never overlap: its indexing and embedding-provider switch coordination is process-local. Keep `memd.replicaCount=1` and `memd.autoscaling.enabled=false` until -[issue #55](https://github.com/fullstack-ai-infra/mem/issues/55) provides +[issue #55](https://github.com/bytefolk/mem/issues/55) provides cross-replica index generations. `Recreate` trades availability for correctness: plan a brief memd API interruption during upgrades. The migration stays single-run. @@ -439,7 +439,7 @@ The hosted service should reuse the multi-node topology, not the single-node Compose profile: - replicated Web and Worker across failure domains; keep one memd until - [issue #55](https://github.com/fullstack-ai-infra/mem/issues/55) enables + [issue #55](https://github.com/bytefolk/mem/issues/55) enables safe cross-replica indexing; - external HA PostgreSQL, Redis and S3; - managed secrets and immutable images; diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 1cd836c..f67b317 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,7 +3,7 @@ This document contains rules that are specific to the `mem` product and code base. The active organization-wide contribution lifecycle, issue and pull request forms, review rules, conduct policy, and support defaults come from -[`fullstack-ai-infra/.github`](https://github.com/fullstack-ai-infra/.github). +[`bytefolk/.github`](https://github.com/bytefolk/.github). This repository owns only `mem`-specific product, validation, security, ownership, triage, and release rules. Do not copy organization defaults back into this repository: GitHub treats local community files as whole-file or diff --git a/docs/DURABLE_CONTEXT.md b/docs/DURABLE_CONTEXT.md index 5ea3586..af0507f 100644 --- a/docs/DURABLE_CONTEXT.md +++ b/docs/DURABLE_CONTEXT.md @@ -3,7 +3,7 @@ Status: additive read-only contract for resuming explicitly approved, workspace-scoped active memory across sessions and channels. -Requirement: [mem#70](https://github.com/fullstack-ai-infra/mem/issues/70) +Requirement: [mem#70](https://github.com/bytefolk/mem/issues/70) REQ-001 / AC-001. ## Why one pinned contract diff --git a/docs/integrations/qoder-ingest.md b/docs/integrations/qoder-ingest.md index c7b97bb..8bf00fb 100644 --- a/docs/integrations/qoder-ingest.md +++ b/docs/integrations/qoder-ingest.md @@ -52,7 +52,7 @@ carry no ingestible text, or are JSON-LD continuations are skipped rather than failing the run. Undocumented fields do not block ingestion. > The connector targets the transcript store described in -> [fullstack-ai-infra/mem#103](https://github.com/fullstack-ai-infra/mem/issues/103). +> [bytefolk/mem#103](https://github.com/bytefolk/mem/issues/103). > If your CLI emits a materially different shape, the parser keys live in > `server/cmd/mem/qoder_transcript.go` and are trivially extended. @@ -98,7 +98,7 @@ mem search "recruit rubric" --path /AgentTranscripts ## MyContext interop (follow-up, not a blocker) -[#103](https://github.com/fullstack-ai-infra/mem/issues/103) accepts MyContext +[#103](https://github.com/bytefolk/mem/issues/103) accepts MyContext interop as a deferred follow-up (AC-003). The connector is structured so a MyContext bridge can reuse the same normalize-and-write path later: @@ -109,4 +109,4 @@ MyContext bridge can reuse the same normalize-and-write path later: capturing session streams at the runtime level would avoid parsing files at all. -Neither is required for this connector to be useful today. \ No newline at end of file +Neither is required for this connector to be useful today. diff --git a/npm/README.md b/npm/README.md index 6a62e8a..7d6665c 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,6 +1,6 @@ # mem-mcp -> npm wrapper for [fullstack-ai-infra/mem](https://github.com/fullstack-ai-infra/mem) — a portable, self-hosted memory plane for AI agents. +> npm wrapper for [bytefolk/mem](https://github.com/bytefolk/mem) — a portable, self-hosted memory plane for AI agents. This package distributes the `mem-mcp` stdio MCP server binary so it can be installed with `npm install` and launched by any MCP-compatible host (Claude Desktop, Cursor, Cline, Codex, etc.). @@ -93,8 +93,8 @@ claude mcp add --scope project --transport stdio \ - **Protocol**: MCP 2024-11-05 - **Tools**: 26 built-in tools (put, get, search, context, remember, checkpoint, resume, etc.) -See the [full tool list](https://github.com/fullstack-ai-infra/mem/blob/main/docs/mcp.md) for details. +See the [full tool list](https://github.com/bytefolk/mem/blob/main/docs/mcp.md) for details. ## About mem -mem is an open-source, self-hosted memory plane — one core across API, MCP, CLI, and UI. It keeps files, metadata, and embeddings under your control. Learn more at [github.com/fullstack-ai-infra/mem](https://github.com/fullstack-ai-infra/mem). +mem is an open-source, self-hosted memory plane — one core across API, MCP, CLI, and UI. It keeps files, metadata, and embeddings under your control. Learn more at [github.com/bytefolk/mem](https://github.com/bytefolk/mem). diff --git a/npm/install.js b/npm/install.js index f293109..58f11ee 100644 --- a/npm/install.js +++ b/npm/install.js @@ -31,7 +31,7 @@ const { TextDecoder } = require("util"); const { assetFor } = require("./platforms"); const PACKAGE = "@fullstack-ai-infra/mem-mcp"; -const REPO = "fullstack-ai-infra/mem"; +const REPO = "bytefolk/mem"; const CHECKSUM_ASSET = "mem-mcp-checksums.txt"; const MAX_CHECKSUM_BYTES = 64 * 1024; const MAX_REDIRECTS = 5; diff --git a/npm/install.test.js b/npm/install.test.js index c22126d..28ad7a6 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -282,7 +282,6 @@ test("install verifies a temporary download before exposing it", async (t) => { osPlatform, osArch, version: "0.1.1", - repository: "example/mem", environment: { MEM_MCP_CACHE_DIR: cacheRoot }, homeDirectory: join(root, "read-only-package-home-must-not-be-used"), logger: QUIET_LOGGER, @@ -303,8 +302,8 @@ test("install verifies a temporary download before exposing it", async (t) => { } assert.deepEqual(readdirSync(cacheDir), [asset]); assert.deepEqual(requested, [ - "https://github.com/example/mem/releases/download/v0.1.1/mem-mcp-checksums.txt", - `https://github.com/example/mem/releases/download/v0.1.1/${asset}`, + "https://github.com/bytefolk/mem/releases/download/v0.1.1/mem-mcp-checksums.txt", + `https://github.com/bytefolk/mem/releases/download/v0.1.1/${asset}`, ]); }); diff --git a/npm/mem-mcp b/npm/mem-mcp index bb6bb3c..9850dd4 100644 --- a/npm/mem-mcp +++ b/npm/mem-mcp @@ -104,7 +104,7 @@ async function runProcess(options = {}) { `mem-mcp: failed to download and verify the platform binary: ${err.message}`, ); logger.error( - `mem-mcp: retry the command, or build manually from https://github.com/fullstack-ai-infra/mem`, + `mem-mcp: retry the command, or build manually from https://github.com/bytefolk/mem`, ); return { code: 1, signal: null }; } finally { diff --git a/npm/package.json b/npm/package.json index 4afd5ad..9481628 100644 --- a/npm/package.json +++ b/npm/package.json @@ -13,13 +13,13 @@ "mcp-server" ], "license": "Apache-2.0", - "homepage": "https://github.com/fullstack-ai-infra/mem", + "homepage": "https://github.com/bytefolk/mem", "repository": { "type": "git", - "url": "git+https://github.com/fullstack-ai-infra/mem.git" + "url": "git+https://github.com/bytefolk/mem.git" }, "bugs": { - "url": "https://github.com/fullstack-ai-infra/mem/issues" + "url": "https://github.com/bytefolk/mem/issues" }, "bin": { "mem-mcp": "./mem-mcp" diff --git a/npm/server.json b/npm/server.json index 0c9acef..f214f7a 100644 --- a/npm/server.json +++ b/npm/server.json @@ -48,6 +48,6 @@ "mem_durable_context_recall" ], "categories": ["memory", "knowledge-management", "ai-agents"], - "repo": "https://github.com/fullstack-ai-infra/mem", + "repo": "https://github.com/bytefolk/mem", "license": "Apache-2.0" } diff --git a/npm/windows-shim.test.js b/npm/windows-shim.test.js index db4163a..2036736 100644 --- a/npm/windows-shim.test.js +++ b/npm/windows-shim.test.js @@ -150,7 +150,7 @@ https.get = (url, _options, callback) => { assert.match(invocation.stderr, /verified existing binary/); assert.deepEqual( readFileSync(requestLog, "utf8").trim().split("\n"), - [`https://github.com/fullstack-ai-infra/mem/releases/download/v${PACKAGE_VERSION}/mem-mcp-checksums.txt`], + [`https://github.com/bytefolk/mem/releases/download/v${PACKAGE_VERSION}/mem-mcp-checksums.txt`], ); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/scripts/validate_release_version.sh b/scripts/validate_release_version.sh index b376c58..987a304 100755 --- a/scripts/validate_release_version.sh +++ b/scripts/validate_release_version.sh @@ -85,7 +85,7 @@ heading_count="$(grep -Fc -- "## [${version}] - " "${changelog}" || true)" [[ "${heading_count}" == 1 ]] || die "CHANGELOG.md: expected exactly one ${version} release heading" require_exact_line CHANGELOG.md \ - "[Unreleased]: https://github.com/fullstack-ai-infra/mem/compare/v${version}...HEAD" + "[Unreleased]: https://github.com/bytefolk/mem/compare/v${version}...HEAD" version_links=() while IFS= read -r version_link; do @@ -94,9 +94,9 @@ done < <(grep -F -- "[${version}]: " "${changelog}" || true) [[ "${#version_links[@]}" == 1 ]] || die "CHANGELOG.md: expected exactly one [${version}] comparison link" if [[ "${version_links[0]}" != \ - "[${version}]: https://github.com/fullstack-ai-infra/mem/releases/tag/v${version}" && + "[${version}]: https://github.com/bytefolk/mem/releases/tag/v${version}" && "${version_links[0]}" != \ - "[${version}]: https://github.com/fullstack-ai-infra/mem/compare/"*"...v${version}" ]]; then + "[${version}]: https://github.com/bytefolk/mem/compare/"*"...v${version}" ]]; then die "CHANGELOG.md: [${version}] link must terminate at v${version}" fi From 1332bf46c0c598cdc63974db6b776445cda0f26c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:44:12 +0800 Subject: [PATCH 02/31] ci(release): add npm-publish job (OIDC Trusted Publishing) for mem-mcp (#152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `npm-publish` job to release.yml implementing G4 via npm OIDC Trusted Publishing (founder 08-30 ruling). - `permissions: contents: read` + `id-token: write` - `cd npm && npm publish --provenance --access public` - No `NPM_TOKEN` / `NODE_AUTH_TOKEN` — auth is exchanged from the GitHub OIDC id-token against the npmjs Trusted Publisher (org=fullstack-ai-infra / repo=mem / workflow=release.yml), which the founder configures on npmjs.com. - checkout pinned to the same SHA as existing steps; node 22; `needs: [release]` so it runs after the GitHub Release (G1) exists and install.js can pull binaries. Dependency order held: G1 (v0.1.0 GitHub Release) first; install.js pulls binaries from the Release at install time. Refs #104 --- .github/workflows/npm-publish.yml | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/npm-publish.yml diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..dc94336 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,71 @@ +name: NPM Publish + +# G4 (npm publish) lives in its own workflow because scripts/test_release_guards.sh +# forbids `npm publish` inside release.yml and requires the draft publication to +# remain release.yml's final command. G1 (GitHub Release) must exist first: +# install.js pulls the platform binaries from the Release at install time. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Existing published release tag to publish to npm (e.g., v0.1.0)" + required: true + type: string + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ inputs.version || github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + npm-publish: + name: Publish npm package (OIDC Trusted Publishing) + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Resolve release tag + id: tag + env: + INPUT_VERSION: ${{ inputs.version }} + RELEASE_TAG_NAME: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + TAG="${INPUT_VERSION:-${RELEASE_TAG_NAME}}" + if [[ ! "${TAG}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "expected a release tag such as v0.1.0, got: ${TAG:-}" >&2 + exit 1 + fi + printf 'tag=%s\n' "${TAG}" >> "${GITHUB_OUTPUT}" + + - name: Check out exact tag commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/tags/${{ steps.tag.outputs.tag }} + fetch-depth: 0 + persist-credentials: false + + - name: Require the GitHub Release (G1) to exist before npm publish (G4) + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.tag.outputs.tag }} + run: | + set -euo pipefail + gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Publish @fullstack-ai-infra/mem-mcp (OIDC) + working-directory: npm + # No NPM_TOKEN / NODE_AUTH_TOKEN: auth is exchanged from the GitHub OIDC + # id-token against the npmjs Trusted Publisher configured for + # org=fullstack-ai-infra / repo=mem / workflow=npm-publish.yml. + run: npm publish --provenance --access public From 2e4ec4613bb55bd18534f092981c668495e8e503 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:05:01 +0800 Subject: [PATCH 03/31] build(deps-dev): bump browserslist from 4.28.2 to 4.28.8 in /web (#159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [browserslist](https://github.com/browserslist/browserslist) from 4.28.2 to 4.28.8.
Release notes

Sourced from browserslist's releases.

4.28.8

  • Fixed including kaios in baseline queries (by @​Jaybhade).

4.28.7

4.28.6

4.28.5

4.28.4

  • Fixed SyntaxError regression of 4.28.3.

4.28.3

  • Fixed baseline query case-insensitivity (by @​swwind).
Changelog

Sourced from browserslist's changelog.

4.28.8

  • Fixed including kaios in baseline queries (by @​Jaybhade).

4.28.7

4.28.6

4.28.5

4.28.4

  • Fixed SyntaxError regression of 4.28.3.

4.28.3

  • Fixed baseline query case-insensitivity (by @​swwind).
Commits
Maintainer changes

This version was pushed to npm by GitHub Actions, a new releaser for browserslist since your current version.


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=browserslist&package-manager=npm_and_yarn&previous-version=4.28.2&new-version=4.28.8)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/bytefolk/mem/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> --- web/package-lock.json | 51 +++++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 2bd86c8..103ae42 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2461,9 +2461,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.30", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", - "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2520,9 +2520,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2540,11 +2540,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2574,9 +2574,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2934,9 +2934,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.357", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", - "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "dev": true, "license": "ISC" }, @@ -4410,11 +4410,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.44", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", - "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -5824,9 +5827,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { From 4cf4452ee3e05cf28509f7ab575ac9aaf26393fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:32:06 +0800 Subject: [PATCH 04/31] chore(codeowners): add third independent reviewer (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Tracking record Refs #124 - Consumed revision: [#124 R4](https://github.com/bytefolk/mem/issues/124#issuecomment-5478114232) - This candidate does not close #124. Merge-SHA evidence and product acceptance remain separate. ## Bounded change The exact diff against main changes one global CODEOWNERS line: ```diff -* @PeterGuy326 @Bindy-lbb +* @PeterGuy326 @Bindy-lbb @waterbro-8 ``` No collaborator role, branch-protection setting, required-review count, administrator bypass, tag ruleset, tag permission, release, or repository setting changes. ## Requirement trace | Requirement / acceptance | Evidence | | --- | --- | | REQ-001; AC-001 | Exact one-line .github/CODEOWNERS diff from base cc727db0bc72655f299166de1f60756f5c686cc7 to head 474fb334478a0569bed903181cca2d54e44abd95. | | REQ-002; AC-002 | Before-state GitHub protection readback confirms strict checks, code-owner review, stale-review dismissal, last-push approval, linear history, admin enforcement, force-push prohibition, and deletion prohibition remain enabled. Exact-head CI is required. | | REQ-003; AC-003 | Diff contains no tag or collaborator configuration; an independent current-head CODEOWNER review remains required. | ## Validation - git diff --check: passed. - git diff --name-only origin/main...HEAD: only .github/CODEOWNERS. - Git author and committer: PeterGuy326 using 47820304+PeterGuy326@users.noreply.github.com. - Sensitive-content scan: no credentials or private data introduced. ## Review and delivery gates This clean candidate supersedes [#132](https://github.com/bytefolk/mem/pull/132) because that branch has public commits with non-noreply personal metadata. #132 is left unchanged for audit and is not force-pushed or merged. @Bindy-lbb must provide a current-head independent CODEOWNER approval after all required CI is green. The author and last pusher will not self-approve. Normal merge authorization, merge-SHA push/main checks, verification ledger, and product acceptance still apply. Co-authored-by: Bindy <70745012+Bindy-lbb@users.noreply.github.com> Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c20040f..0df41ca 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,3 @@ # Changes require review from the organization administrator or the designated # independent reviewer, so code-owner review is satisfiable without admin bypass. -* @PeterGuy326 @Bindy-lbb +* @PeterGuy326 @Bindy-lbb @waterbro-8 From 87d235b7cffeb9d9e49653760b5362c792366900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Thu, 3 Sep 2026 15:41:35 +0800 Subject: [PATCH 05/31] fix(web): make the proxy the single authority for the shared security headers (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this changes Implements the security-header split decided for this repository on 2026-09-03: **nginx is the single authority for `X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy`, uniformly `no-referrer`, with the proxied path de-duplicated by `proxy_hide_header`; the API keeps `Content-Security-Policy`, `X-XSS-Protection` and `Content-Disposition`.** Closes #135 (the two live proxy defects) and Closes #136 (the ownership question that defect raised). This is the in-repo redo of #144, which came from a fork and is being closed under the fork-policy decision. It is not a cherry-pick of that commit — no commit from it is imported here. ### The two defects, as measured Both were real on `main@1332bf46` and neither is visible by reading the config, which is why the test starts an nginx rather than grepping one. 1. **`/assets/` lost all three headers.** An `add_header` inside a location replaces the inherited set instead of adding to it, and that block has always had `add_header Cache-Control`. Every cached bundle shipped with no `nosniff`, no `X-Frame-Options` and no `Referrer-Policy` at all. 2. **`/v1/` sent two conflicting `Referrer-Policy` values on one response.** The proxy said `same-origin`, the API says `no-referrer`, and nginx's `add_header` appends rather than replaces, so a client received both and the effective policy depended on which one the browser kept. `X-Content-Type-Options` and `X-Frame-Options` were also duplicated (same value twice). ### Why the API keeps sending the three it no longer owns `proxy_hide_header` makes the wire value nginx's, which is what "nginx 独占" requires, without deleting `nosniff`/`DENY`/`no-referrer` from `securityHeadersMiddleware`. A `memd` reached directly — the Helm path exposes the Service, and `docs/DEPLOYMENT.md` only makes the nginx guarantee for the container — keeps its defense in depth. If the intended reading was instead that the Go middleware should stop setting them, that is a one-line change here and it should be said before merge, because the alternative silently weakens the no-proxy deployment. ## Test evidence `scripts/test_nginx_security_headers.sh` renders the shipped template with the same `envsubst` filter and variables the container entrypoint uses, runs a fake upstream that answers exactly like `securityHeadersMiddleware` does, starts nginx against the config, and reads headers off the wire across five surfaces (`/`, `/assets/`, a 404 under `/assets/`, `/v1/`, `/healthz`). | run | result | | --- | --- | | template as shipped on `main` | **11 of 28 assertions fail** — 6 missing across the two `/assets/` surfaces, `same-origin` on `/` and `/healthz`, 3 duplicated on `/v1/` | | this branch | **28 of 28 pass** | | drop the `/assets/` restatement | 3 fail (`/assets/` 200) | | drop `always` from the `/assets/` restatement | 3 fail (`/assets/` 404 only) | | drop `proxy_hide_header` | 3 fail (`/v1/` duplicates) | Each of the three fix sites is therefore individually load-bearing, and the 404 surface earns its place: it is the only one that tests `always`. The harness also refuses to report success on a partial run (the assertion count is pinned), hard-fails when a caller names an `NGINX_BIN` that is not executable, and only reports `SKIP` when no nginx was found at all — so the CI leg cannot go green by measuring nothing. Executed locally against nginx **1.27.4**, the same minor the `web/Dockerfile` pins (`nginxinc/nginx-unprivileged:1.27.4-alpine3.21`). **Measured in CI on the built image, and it did go red there first.** The first run of the new step failed exactly where this paragraph expected the risk to be: `web/Dockerfile:17` ends on `nginxinc/nginx-unprivileged`, whose own build stops at `USER 101`, so `apk add` could not write the package database -- `ERROR: Unable to lock database: Permission denied`, `exit code: 99`. Commit `1c917f96` installs the four tools in a throwaway child image that returns to uid 101 afterwards, so the harness still runs unprivileged, as the shipped container does. On that head `Deployment profiles` is green and its job log carries `all 28 security-header assertions passed`, so the leg executed the contract against the nginx that ships rather than printing `SKIP`. The harness content measured above is unchanged by that commit; it touches only the workflow step. ## Not in scope here, and worth a decision Nothing in this repository sets a `Content-Security-Policy` for the SPA itself — `default-src 'none'` is an API-only value and would break the web app if applied to it. The decided split covers the three shared headers and leaves HTML/SPA CSP unowned. This PR deliberately does not invent one. ## Links and state - `Refs` the decided split; `Closes #135` / `Closes #136` on merge. - No Go code changed, so the existing `security_headers_test.go` and `content_*` tests are unaffected. - `Web` is red on this head and identically red on `main` itself (`Audit dependencies`, `browserslist <=4.28.6`, GHSA-73wf-gq98-2v4g). That is a pre-existing repository condition rather than something this branch introduced, and the queued fix is #159; the readback is on #138. - Opening this PR is not a claim that it should be merged. Independent review is the reviewers'; I have not requested or recorded one. --------- Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> --- .github/workflows/ci.yml | 18 ++ CHANGELOG.md | 11 ++ docs/DEPLOYMENT.md | 8 + scripts/test_nginx_security_headers.sh | 248 +++++++++++++++++++++++++ scripts/verify.sh | 7 + web/nginx/default.conf.template | 18 +- 6 files changed, 309 insertions(+), 1 deletion(-) create mode 100755 scripts/test_nginx_security_headers.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b636850..a6f912f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -364,3 +364,21 @@ jobs: - name: Validate deployment configuration and production images run: make test-deploy-build + + - name: Validate web security response headers + run: | + # The header contract is measured against the nginx that actually ships, + # so it runs on the image the step above built. That image ends its own + # build at USER 101, which cannot install packages, so the tools go into + # a throwaway child image that returns to that user id afterwards -- the + # harness then still runs unprivileged, as the container does in + # production. + printf 'FROM mem-web:deploy-validation\nUSER root\nRUN apk add --no-cache bash curl python3 gettext\nUSER 101\n' \ + >"${RUNNER_TEMP}/headers.Dockerfile" + docker build --tag mem-web-headers:validation \ + -f "${RUNNER_TEMP}/headers.Dockerfile" "${RUNNER_TEMP}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/src:ro" \ + --env NGINX_BIN=/usr/sbin/nginx \ + mem-web-headers:validation \ + bash /src/scripts/test_nginx_security_headers.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 583287f..5c094d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,17 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - Add `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` and `Content-Security-Policy: default-src 'none'` to every API response, ordered outside the CORS handler so a preflight reply carries them too. +- Make the web proxy the single authority for `X-Content-Type-Options`, + `X-Frame-Options` and `Referrer-Policy` on every response it serves, with one + `Referrer-Policy: no-referrer` instead of the `same-origin` it shipped before, + which contradicted the API's own value and let both reach a client on the + proxied path. The API's copies are hidden at the proxy rather than removed + from the API, so a `memd` running without a proxy keeps its defense in depth. + Cached assets carried none of the three: a local `add_header` for + `Cache-Control` replaced the inherited set entirely, so the set is now + restated in that location. `scripts/test_nginx_security_headers.sh` measures + the headers off a running nginx, since neither failure mode is visible by + reading the configuration. ## [0.1.1] - 2026-08-31 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 83acc7a..801e41d 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -120,6 +120,14 @@ Terminate HTTPS at a maintained reverse proxy or load balancer. Forward to `http://127.0.0.1:8080`, preserve the `Host` and `X-Forwarded-*` headers, and set an upload-body limit at least as large as `MEM_MAX_BODY_SIZE`. +The web container is itself a reverse proxy and is the authority for +`X-Content-Type-Options`, `X-Frame-Options` and `Referrer-Policy`; it sets them +on every response it serves and drops the copies `memd` sends so they do not +arrive twice. `Content-Security-Policy`, `X-XSS-Protection` and +`Content-Disposition` come from `memd`, because they depend on what the response +actually is. If your terminating proxy sets the first three as well, set them +there or here, not both, or a client receives two values for one header. + ### Configure and start From the repository root: diff --git a/scripts/test_nginx_security_headers.sh b/scripts/test_nginx_security_headers.sh new file mode 100755 index 0000000..35ab9b9 --- /dev/null +++ b/scripts/test_nginx_security_headers.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +# Security response-header contract for the web reverse proxy. +# +# nginx is the single authority for X-Content-Type-Options, X-Frame-Options and +# Referrer-Policy; the API owns Content-Security-Policy, X-XSS-Protection and +# Content-Disposition. This runs a real nginx because both failure modes are +# runtime semantics that reading the config cannot prove: add_header in a nested +# block silently voids the inherited set, and an upstream header survives the +# proxy unless it is explicitly hidden. +# +# Usage: scripts/test_nginx_security_headers.sh [path-to-nginx-binary] +set -euo pipefail + +repo_root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +nginx_bin=${1:-${NGINX_BIN:-}} + +if [[ -n "$nginx_bin" && ! -x "$nginx_bin" ]]; then + # An explicitly requested binary that is not there must not become a green + # run: a caller that pins NGINX_BIN is asserting that the check executed. + printf 'ERROR: requested nginx binary is not executable: %s\n' "$nginx_bin" >&2 + exit 1 +fi +if [[ -z "$nginx_bin" ]]; then + nginx_bin=$(command -v nginx || true) +fi +if [[ -z "$nginx_bin" ]]; then + printf 'SKIP: no nginx binary found (pass one as $1 or set NGINX_BIN)\n' + exit 0 +fi +for tool in curl envsubst python3; do + command -v "$tool" >/dev/null 2>&1 || { + printf 'ERROR: %s is required to drive the proxy\n' "$tool" >&2 + exit 1 + } +done + +port=${PORT:-18080} +upstream_port=${UPSTREAM_PORT:-18081} +# 3 proxy-owned headers on five surfaces, + the 3 API-owned headers required on +# /v1/ and the 2 required absent on the other four, + the not-found status guard, +# + the /assets/ Cache-Control guard. +expected_checks=28 +work=$(mktemp -d) +upstream_pid='' +nginx_pid='' +failures=0 +checks=0 + +cleanup() { + [[ -n "$nginx_pid" ]] && kill "$nginx_pid" 2>/dev/null || true + [[ -n "$upstream_pid" ]] && kill "$upstream_pid" 2>/dev/null || true + wait 2>/dev/null || true + rm -rf "$work" +} +trap cleanup EXIT HUP INT TERM + +fail() { printf ' not ok - %s\n' "$*" >&2; failures=$((failures + 1)); checks=$((checks + 1)); } +pass() { printf ' ok - %s\n' "$*"; checks=$((checks + 1)); } + +# --- render the shipped template the way the container entrypoint does --- +prefix=$work +mkdir -p "$prefix/conf" "$prefix/logs" "$prefix/run" "$prefix/html/assets" \ + "$prefix/tmp/client" "$prefix/tmp/proxy" "$prefix/tmp/fastcgi" \ + "$prefix/tmp/uwsgi" "$prefix/tmp/scgi" +printf 'console.log(1)\n' >"$prefix/html/assets/app.js" +printf 'mem\n' >"$prefix/html/index.html" + +export MEMD_UPSTREAM="http://127.0.0.1:${upstream_port}" +export MEM_MAX_BODY_SIZE=256m +export NGINX_ENVSUBST_FILTER='^(MEMD_UPSTREAM|MEM_MAX_BODY_SIZE)$' +rendered=$work/default.conf +envsubst '$MEMD_UPSTREAM $MEM_MAX_BODY_SIZE' \ + <"$repo_root/web/nginx/default.conf.template" >"$rendered" +if grep -Eq '\$\{[A-Z_]+\}' "$rendered"; then + printf 'the template left a variable unexpanded:\n' >&2 + grep -Eo '\$\{[A-Z_]+\}' "$rendered" | sort -u >&2 + exit 1 +fi + +mime_candidates=( + "$(cd -- "$(dirname -- "$nginx_bin")/.." && pwd)/conf/mime.types" + /etc/nginx/mime.types +) +mime_types='' +for candidate in "${mime_candidates[@]}"; do + if [[ -f "$candidate" ]]; then mime_types=$candidate; break; fi +done +if [[ -z "$mime_types" ]]; then + printf 'no mime.types found near %s\n' "$nginx_bin" >&2 + exit 1 +fi + +python3 - "$rendered" "$work/conf/nginx.conf" "$prefix" "$port" "$mime_types" <<'PY' +import sys + +conf_path, out_path, prefix, port, mime_types = sys.argv[1:6] +with open(conf_path, encoding="utf-8") as handle: + server = handle.read() +server = server.replace("listen 8080;", "listen 127.0.0.1:%s;" % port) +server = server.replace("root /usr/share/nginx/html;", "root %s/html;" % prefix) +if "listen 127.0.0.1:%s;" % port not in server or ("%s/html" % prefix) not in server: + raise SystemExit("the shipped server block drifted: could not rebind it for the harness") + +with open(out_path, "w", encoding="utf-8") as handle: + handle.write(""" +worker_processes 1; +error_log {prefix}/logs/error.log warn; +pid {prefix}/run/nginx.pid; +daemon off; + +events {{ worker_connections 64; }} + +http {{ + include {mime_types}; + default_type application/octet-stream; + access_log {prefix}/logs/access.log; + client_body_temp_path {prefix}/tmp/client; + proxy_temp_path {prefix}/tmp/proxy; + fastcgi_temp_path {prefix}/tmp/fastcgi; + uwsgi_temp_path {prefix}/tmp/uwsgi; + scgi_temp_path {prefix}/tmp/scgi; + +{server} +}} +""".format(prefix=prefix, server=server.rstrip(), mime_types=mime_types)) +PY + +# --- fake upstream answering exactly like the Go API middleware does --- +cat >"$work/upstream.py" <<'PY' +import http.server +import os + +# Mirrors securityHeadersMiddleware (server/internal/api/util.go) plus the +# per-response Content-Disposition that the download handlers set. If the API's +# header set changes, this fixture must change with it. +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): # noqa: N802 + body = b'{"ok":true}' + self.send_response(200) + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("Content-Security-Policy", "default-src 'none'") + self.send_header("X-XSS-Protection", "0") + self.send_header("Content-Disposition", 'attachment; filename="note.txt"') + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + +http.server.HTTPServer(("127.0.0.1", int(os.environ["UPSTREAM_PORT"])), Handler).serve_forever() +PY +UPSTREAM_PORT=$upstream_port python3 "$work/upstream.py" & +upstream_pid=$! + +"$nginx_bin" -p "$prefix" -c "$work/conf/nginx.conf" & +nginx_pid=$! + +ready='' +for _ in $(seq 1 50); do + if curl -fsS -o /dev/null "http://127.0.0.1:${port}/v1/ping" 2>/dev/null; then ready=yes; break; fi + sleep 0.2 +done +if [[ -z "$ready" ]]; then + printf 'nginx never became ready; error log:\n' >&2 + cat "$prefix/logs/error.log" >&2 || true + exit 1 +fi + +header_values() { + # No -f: a 404 or a 502 has to be probeable too, because `always` is what + # keeps these headers on an error response. + curl -sS -D - -o /dev/null "$1" | tr -d '\r' | + awk -v h="$2" ' + /^$/ { exit } + tolower($0) ~ "^" tolower(h) ":" { sub(/^[^:]*:[ \t]?/, ""); print }' +} + +check_single() { + local url=$1 header=$2 want=$3 got count + got=$(header_values "$url" "$header") + count=$(printf '%s' "$got" | grep -c . || true) + if [[ $count -ne 1 ]]; then + fail "${url##*/}: $header appears $count times, want exactly 1 ($(printf '%s' "$got" | tr '\n' '|'))" + elif [[ $got != "$want" ]]; then + fail "${url##*/}: $header = $got, want $want" + else + pass "${url##*/}: $header: $got" + fi +} + +check_absent() { + local url=$1 header=$2 count + count=$(header_values "$url" "$header" | grep -c . || true) + if [[ $count -ne 0 ]]; then + fail "${url##*/}: $header present, want absent (nginx must not set what the API owns)" + else + pass "${url##*/}: $header absent" + fi +} + +for path in /index.html /assets/app.js /assets/does-not-exist.js /v1/ping /healthz; do + printf '\n== %s ==\n' "$path" + url="http://127.0.0.1:${port}${path}" + check_single "$url" X-Content-Type-Options nosniff + check_single "$url" X-Frame-Options DENY + check_single "$url" Referrer-Policy no-referrer + if [[ $path == /v1/* ]]; then + check_single "$url" Content-Security-Policy "default-src 'none'" + check_single "$url" X-XSS-Protection 0 + check_single "$url" Content-Disposition 'attachment; filename="note.txt"' + else + check_absent "$url" Content-Security-Policy + check_absent "$url" Content-Disposition + fi +done + +printf '\n== the not-found surface really was a not-found ==\n' +status=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/assets/does-not-exist.js") +if [[ $status == 404 ]]; then + pass "/assets/does-not-exist.js: HTTP $status" +else + fail "/assets/does-not-exist.js: HTTP $status, want 404 -- the header assertions above would then be measuring a success response, not the always flag" +fi + +printf '\n== /assets/ caching is not collateral damage ==\n' +got=$(header_values "http://127.0.0.1:${port}/assets/app.js" Cache-Control) +if [[ $got == *immutable* ]]; then + pass "/assets/app.js: Cache-Control: $got" +else + fail "/assets/app.js: Cache-Control = ${got:-}, want it to still say immutable" +fi + +printf '\n' +if [[ $checks -ne $expected_checks ]]; then + printf '%d security-header assertions ran, expected %d\n' "$checks" "$expected_checks" >&2 + exit 1 +fi +if [[ $failures -ne 0 ]]; then + printf '%d of %d security-header assertions failed\n' "$failures" "$checks" >&2 + exit 1 +fi +printf 'all %d security-header assertions passed\n' "$checks" diff --git a/scripts/verify.sh b/scripts/verify.sh index e1aa3c8..eed61b0 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -117,6 +117,11 @@ run_web() { (cd "${REPO_ROOT}/web" && npm run test:transfer) } +run_web_headers() { + log "Web reverse-proxy security headers" + "${REPO_ROOT}/scripts/test_nginx_security_headers.sh" +} + validate_test_database() { [[ -n "${MEM_TEST_DB:-}" ]] \ || die "MEM_TEST_DB is required; run: make test-env-up" @@ -423,6 +428,7 @@ case "$MODE" in run_server run_worker run_web + run_web_headers ;; race) run_race ;; integration) run_integration ;; @@ -431,6 +437,7 @@ case "$MODE" in run_server run_worker run_web + run_web_headers run_race run_integration run_integration_race diff --git a/web/nginx/default.conf.template b/web/nginx/default.conf.template index 48b4382..72de1db 100644 --- a/web/nginx/default.conf.template +++ b/web/nginx/default.conf.template @@ -7,9 +7,16 @@ server { index index.html; client_max_body_size ${MEM_MAX_BODY_SIZE}; + # nginx is the single authority for these three headers on every response + # that leaves this container, so the values below are the values a client + # sees. The API sets the same three itself for deployments that run memd + # without a proxy in front; /v1/ hides those copies so they cannot arrive + # alongside these ones. Content-Security-Policy, X-XSS-Protection and + # Content-Disposition stay the API's, because they depend on what the + # response actually is and nginx cannot know that. add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "same-origin" always; add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; location = /healthz { access_log off; @@ -18,6 +25,9 @@ server { } location /v1/ { + proxy_hide_header X-Content-Type-Options; + proxy_hide_header X-Frame-Options; + proxy_hide_header Referrer-Policy; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -32,6 +42,12 @@ server { location /assets/ { try_files $uri =404; expires 1y; + # An add_header in this block replaces the inherited set rather than + # adding to it, so the three headers above have to be restated here or + # every cached bundle ships without them. + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; add_header Cache-Control "public, immutable"; } From 7a194f1eba4167d54bd46cf84cdbe86e00532319 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:43:05 +0800 Subject: [PATCH 06/31] fix(npm): bound Windows cache-lock contention retries (#137) ## Tracking record Refs #133 - Consumed revision: [#133 R1](https://github.com/bytefolk/mem/issues/133#issuecomment-5482768459) - This PR does not close the Issue. Product acceptance remains a separate step after merge evidence. ## Why A concurrent cold-cache install on Windows can surface mkdir contention as EPERM or EACCES rather than EEXIST. The installer previously treated only EEXIST as retryable. The original remediation candidate [#134](https://github.com/bytefolk/mem/pull/134) also left a P1 retry path: an EEXIST to ENOENT or stale-rename race could retry synchronously before its deadline or abort-aware delay. This is a clean successor to #134. That older public branch is left intact for audit; no history is rewritten or force-pushed. ## Scope - Classify EEXIST as lock contention on every platform and EPERM or EACCES as potential contention only on win32. - Prove a lock can be inspected before retrying a Windows permission-shaped contention error; unproven permission failures still fail closed. - Route every failed acquisition, including stale recovery and lock-disappearance races, through the existing deadline and abort-aware poll. - Add deterministic regression coverage and the Unreleased changelog entry. No lock primitive, per-asset ownership model, stale or orphan threshold, dependency, public API, tag, npm publication, or release behavior changes. ## Requirement trace | Requirement / acceptance | Implementation | Evidence | | --- | --- | --- | | REQ-001; AC-001, AC-002 | npm/install.js error classifier and acquireAssetLock | Host-independent classifier table and real-lock simulated Windows EPERM acquisition test. | | REQ-002; AC-003 | npm/install.js lock inspection path | Persistent no-lock EPERM and inspection EACCES tests reject promptly. | | REQ-003; AC-004 | npm/install.js platform guard | Linux and Darwin keep EPERM or EACCES fatal; exact-head Windows CI remains required. | | REQ-004; AC-001, AC-004 | injectable osPlatform plus npm/install.test.js | win32 branch is exercised off Windows; hosted node24-windows job is required before review. | | AC-005 | CHANGELOG.md | Unreleased user-visible fix note. | ## Failure evidence and regression proof At base cc727db0bc72655f299166de1f60756f5c686cc7, a deterministic pre-require fs injection of EEXIST followed by lstat ENOENT produced two immediate lock mkdir attempts and an EPERM sentinel: the retry bypassed its zero wait deadline. This candidate makes the same path return the expected timeout after one attempt. A separate stale rename ENOENT race has the same bounded behavior. ## Validation Local macOS, Node 24: | Command | Observed result | | --- | --- | | PATH=/Users/huyz/.nvm/versions/node/v24.14.1/bin:$PATH npm test in npm/ | 41 passed, 0 failed, 1 Windows-only shim skipped as expected on macOS | | node --test npm/install.test.js | 30 passed, 0 failed | | node --check install.js; node --check mem-mcp; node --check platforms.js | passed | | npm pack --dry-run --ignore-scripts | passed; six expected package files only | | git diff --check | passed | | focused added-diff sensitive-pattern scan | no matches | Independent preflight replayed the focused and full Node 24 suite, plus the full Node 18 suite: 41 passed, 0 failed, 1 expected Windows-only skip in each full run. It found no P0 or P1 and recorded PREFLIGHT PASS for this code candidate. ## Review and delivery gates - Exact head required CI, especially npm wrapper compatibility (node24-windows), must be green. - @Bindy-lbb must provide an independent current-head CODEOWNER approval. The author and last pusher will not self-approve. - Normal merge authorization, merge-SHA push/main verification, verification ledger, and product acceptance remain required. ## Security, compatibility, and release boundary The retry set is deliberately narrow. On Windows, EPERM or EACCES is retried only after a lock can be inspected; otherwise the original error is surfaced. Non-Windows behavior remains fail-closed. No credentials, dependencies, or public-history changes are introduced. v0.1.1 was already published from main. This candidate may only enter a later, separately authorized release; it does not authorize a tag move, npm publish, GitHub Release, merge, or Issue closure. Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> --- CHANGELOG.md | 10 ++ npm/install.js | 39 +++++-- npm/install.test.js | 272 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 312 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c094d3..104b02f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,16 @@ The project publishes 0.x prerelease versions; a stable release line is not yet the headers off a running nginx, since neither failure mode is visible by reading the configuration. +### Fixed + +- The npm installer no longer aborts a concurrent first run on Windows. The + per-asset cache lock previously treated only `EEXIST` as contention, but a + contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process + waiting for the lock holder failed outright instead of retrying. The retry + path now proves a lock can be inspected before treating those Windows errors + as contention, preserves prompt failure for unrelated permission errors, and + observes its deadline when a competing lock disappears during inspection. + ## [0.1.1] - 2026-08-31 ### Changed diff --git a/npm/install.js b/npm/install.js index 58f11ee..55dcf41 100644 --- a/npm/install.js +++ b/npm/install.js @@ -241,12 +241,25 @@ function ownedArtifactPaths(cacheDir, asset, nonce) { ]; } -function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) { +// Windows reports EPERM/EACCES rather than EEXIST when another process already +// owns the lock directory or is mid-create on it, so either code is ordinary +// contention there and must not be mistaken for a hard permission failure. +function isLockContention(err, currentPlatform = platform()) { + if (err.code === "EEXIST") return true; + return currentPlatform === "win32" && (err.code === "EPERM" || err.code === "EACCES"); +} + +function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError) { let info; try { info = lstatSync(lockPath); } catch (err) { - if (err.code === "ENOENT") return true; + // An EEXIST result followed by ENOENT means a competing owner released + // the lock before inspection. It is safe to retry, but it must take the + // normal deadline/delay path rather than spin synchronously. A Windows + // EPERM/EACCES without a lock to inspect remains a real permission failure. + if (err.code === "ENOENT" && mkdirError.code === "EEXIST") return; + if (err.code === "ENOENT") throw mkdirError; throw err; } if (info.isSymbolicLink() || !info.isDirectory()) { @@ -261,13 +274,15 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) { : alive === true ? false : age >= staleMs; - if (!reclaimable) return false; + if (!reclaimable) return; const quarantine = `${lockPath}.stale.${process.pid}.${randomBytes(12).toString("hex")}`; try { renameSync(lockPath, quarantine); } catch (err) { - if (err.code === "ENOENT" || err.code === "EEXIST") return true; + // Another contender changed the lock after we inspected it. Retrying is + // safe, but uses the normal poll path so repeated races cannot busy-loop. + if (err.code === "ENOENT" || err.code === "EEXIST") return; throw err; } @@ -277,7 +292,6 @@ function reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs) { } } removeOwnedPath(quarantine); - return true; } async function acquireAssetLock(cacheDir, asset, options = {}) { @@ -285,6 +299,7 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { const staleMs = options.staleMs ?? LOCK_STALE_MS; const orphanGraceMs = options.orphanGraceMs ?? LOCK_ORPHAN_GRACE_MS; const pollMs = options.pollMs ?? LOCK_POLL_MS; + const osPlatform = options.osPlatform || platform(); const signal = options.signal; const lockPath = path.join(cacheDir, `.${asset}.lock`); const deadline = Date.now() + waitTimeoutMs; @@ -292,6 +307,7 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { for (;;) { throwIfAborted(signal); const nonce = randomBytes(12).toString("hex"); + let mkdirError; try { mkdirSync(lockPath, { mode: 0o700 }); try { @@ -306,12 +322,15 @@ async function acquireAssetLock(cacheDir, asset, options = {}) { } return { lockPath, nonce }; } catch (err) { - if (err.code !== "EEXIST") throw err; + if (!isLockContention(err, osPlatform)) throw err; + mkdirError = err; } - if (reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs)) { - continue; - } + // All failed acquisitions, including a successfully reclaimed stale lock, + // pass through the same deadline and abort-aware poll. This prevents a + // repeated create/release race from bypassing the wait budget in a tight + // synchronous loop. + reclaimStaleLock(lockPath, cacheDir, asset, staleMs, orphanGraceMs, mkdirError); if (Date.now() >= deadline) { throw new Error(`Timed out waiting for mem-mcp cache lock: ${lockPath}`); } @@ -625,6 +644,7 @@ async function install(options = {}) { throwIfAborted(signal); ensureCacheDirectory(cacheDir); lock = await acquireAssetLock(cacheDir, asset, { + osPlatform, waitTimeoutMs: options.lockWaitTimeoutMs, staleMs: options.lockStaleMs, orphanGraceMs: options.lockOrphanGraceMs, @@ -769,6 +789,7 @@ module.exports = { downloadText, ensureCacheDirectory, install, + isLockContention, openResponse, releaseAssetLock, sha256File, diff --git a/npm/install.test.js b/npm/install.test.js index 28ad7a6..49aa4e5 100644 --- a/npm/install.test.js +++ b/npm/install.test.js @@ -6,6 +6,7 @@ const { createHash } = require("node:crypto"); const { EventEmitter } = require("node:events"); const { chmodSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -21,12 +22,15 @@ const { PassThrough } = require("node:stream"); const test = require("node:test"); const { assetFor } = require("./platforms"); const { + acquireAssetLock, cacheDirectory, cacheRootFor, checksumForAsset, downloadText, install, + isLockContention, openResponse, + releaseAssetLock, } = require("./install"); const ASSET = assetFor("linux", "x64"); @@ -762,3 +766,271 @@ test("a logger failure after atomic publish preserves only the verified final", assert.deepEqual(readFileSync(binPath), bytes); assert.deepEqual(readdirSync(cacheDir), [ASSET]); }); + +test("EEXIST is contention everywhere while permission errors are contention only on win32", () => { + const withCode = (code) => Object.assign(new Error(code), { code }); + assert.equal(isLockContention(withCode("EEXIST"), "linux"), true); + assert.equal(isLockContention(withCode("EEXIST"), "darwin"), true); + assert.equal(isLockContention(withCode("EEXIST"), "win32"), true); + assert.equal(isLockContention(withCode("EPERM"), "win32"), true); + assert.equal(isLockContention(withCode("EACCES"), "win32"), true); + assert.equal(isLockContention(withCode("EPERM"), "linux"), false); + assert.equal(isLockContention(withCode("EACCES"), "darwin"), false); + assert.equal(isLockContention(withCode("ENOENT"), "win32"), false); + assert.equal(isLockContention(new Error("no code"), "win32"), false); +}); + +test("a contended Windows lock reported as EPERM waits for a proven lock", async (t) => { + // install.js destructures mkdirSync at load time, so patch it in a child + // before requiring the module. The real lock proves that EPERM represents + // contention rather than an arbitrary permission failure. + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const { hostname } = require("node:os"); + const { join } = require("node:path"); + const realMkdirSync = fs.mkdirSync; + const cacheDir = ${JSON.stringify(cacheDir)}; + const lockPath = join(cacheDir, ".${ASSET}.lock"); + realMkdirSync(lockPath, { mode: 0o700 }); + fs.writeFileSync( + join(lockPath, "owner.json"), + JSON.stringify({ pid: process.pid, hostname: hostname(), nonce: "a".repeat(24) }) + "\\n", + ); + let armed = true; + fs.mkdirSync = function (target, ...rest) { + if (armed && String(target).endsWith(".lock")) { + armed = false; + throw Object.assign(new Error("simulated Windows contention"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + const { acquireAssetLock, releaseAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + setTimeout(() => fs.rmSync(lockPath, { recursive: true, force: true }), 25).unref(); + acquireAssetLock(cacheDir, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 5000, + }) + .then((lock) => { + const owner = JSON.parse(fs.readFileSync(join(lock.lockPath, "owner.json"), "utf8")); + if (owner.pid !== process.pid) throw new Error("lock is not owned by this process"); + releaseAssetLock(lock); + }) + .catch((error) => { + console.error(error.stack || String(error)); + process.exitCode = 1; + }); + `); + assert.equal(existsSync(join(cacheDir, `.${ASSET}.lock`)), false); +}); + +test("an EEXIST lock that disappears before inspection observes the timeout without spinning", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + const realLstatSync = fs.lstatSync; + const cacheDir = ${JSON.stringify(cacheDir)}; + let attempts = 0; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + attempts += 1; + if (attempts === 1) { + throw Object.assign(new Error("simulated lock contention"), { + code: "EEXIST", + syscall: "mkdir", + }); + } + throw Object.assign(new Error("retry spun past its deadline"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + fs.lstatSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("competing lock released"), { + code: "ENOENT", + syscall: "lstat", + }); + } + return realLstatSync.call(this, target, ...rest); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + acquireAssetLock(cacheDir, ${JSON.stringify(ASSET)}, { + osPlatform: "linux", + pollMs: 1, + waitTimeoutMs: 0, + }) + .then(() => { + throw new Error("expected lock acquisition to time out"); + }) + .catch((error) => { + if (!/Timed out waiting for mem-mcp cache lock/.test(String(error.message))) { + throw error; + } + if (attempts !== 1) { + throw new Error("expected one mkdir attempt before timeout, saw " + attempts); + } + }); + `); +}); + +test("a stale-lock rename race observes the timeout without spinning", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const { join } = require("node:path"); + const realMkdirSync = fs.mkdirSync; + const cacheDir = ${JSON.stringify(cacheDir)}; + const lockPath = join(cacheDir, ".${ASSET}.lock"); + realMkdirSync(lockPath, { mode: 0o700 }); + let attempts = 0; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + attempts += 1; + if (attempts === 1) { + throw Object.assign(new Error("simulated lock contention"), { + code: "EEXIST", + syscall: "mkdir", + }); + } + throw Object.assign(new Error("retry spun past its deadline"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + fs.renameSync = function (source) { + if (String(source).endsWith(".lock")) { + fs.rmSync(lockPath, { recursive: true, force: true }); + throw Object.assign(new Error("competing lock moved first"), { + code: "ENOENT", + syscall: "rename", + }); + } + throw new Error("unexpected rename target"); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + acquireAssetLock(cacheDir, ${JSON.stringify(ASSET)}, { + osPlatform: "linux", + pollMs: 1, + waitTimeoutMs: 0, + staleMs: 0, + orphanGraceMs: 0, + }) + .then(() => { + throw new Error("expected lock acquisition to time out"); + }) + .catch((error) => { + if (!/Timed out waiting for mem-mcp cache lock/.test(String(error.message))) { + throw error; + } + if (attempts !== 1) { + throw new Error("expected one mkdir attempt before timeout, saw " + attempts); + } + }); + `); +}); + +test("a persistent Windows EPERM without a lock fails promptly instead of retrying", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated Windows permission failure"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + const startedAt = Date.now(); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 10000, + }) + .then(() => { + throw new Error("expected a permission failure"); + }) + .catch((error) => { + if (error.code !== "EPERM") throw error; + if (Date.now() - startedAt >= 1000) throw new Error("permission error entered the retry loop"); + }); + `); +}); + +test("a Windows lock inspection permission error fails promptly", async (t) => { + const root = testDirectory(t); + const cacheDir = join(root, "cache"); + mkdirSync(cacheDir, { recursive: true }); + await runWorker(t, ` + const fs = require("node:fs"); + const realMkdirSync = fs.mkdirSync; + const realLstatSync = fs.lstatSync; + fs.mkdirSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated Windows contention"), { + code: "EPERM", + syscall: "mkdir", + }); + } + return realMkdirSync.call(this, target, ...rest); + }; + fs.lstatSync = function (target, ...rest) { + if (String(target).endsWith(".lock")) { + throw Object.assign(new Error("simulated inspection permission failure"), { + code: "EACCES", + syscall: "lstat", + }); + } + return realLstatSync.call(this, target, ...rest); + }; + const { acquireAssetLock } = require(${JSON.stringify(require.resolve("./install"))}); + const startedAt = Date.now(); + acquireAssetLock(${JSON.stringify(cacheDir)}, ${JSON.stringify(ASSET)}, { + osPlatform: "win32", + pollMs: 1, + waitTimeoutMs: 10000, + }) + .then(() => { + throw new Error("expected an inspection failure"); + }) + .catch((error) => { + if (error.code !== "EACCES") throw error; + if (Date.now() - startedAt >= 1000) throw new Error("inspection error entered the retry loop"); + }); + `); +}); + +test("a non-contention error propagates immediately instead of entering the wait loop", async (t) => { + const root = testDirectory(t); + const missing = join(root, "no-such-parent", "cache"); + const startedAt = Date.now(); + await assert.rejects( + acquireAssetLock(missing, ASSET, { osPlatform: "linux", pollMs: 1, waitTimeoutMs: 10000 }), + (error) => error.code === "ENOENT", + ); + assert.ok( + Date.now() - startedAt < 5000, + "expected an immediate rejection, not a wait", + ); +}); From 2986fe38175f54d99f15dd38a498708c6ecd88cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:58:17 +0800 Subject: [PATCH 07/31] ci: enable free security baseline Queued for protected squash merge after independent Code Owner approval and green security checks. --- .github/workflows/bytefolk-scorecard.yml | 49 +++++++++++++++++ .github/workflows/bytefolk-security.yml | 70 ++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/bytefolk-scorecard.yml create mode 100644 .github/workflows/bytefolk-security.yml diff --git a/.github/workflows/bytefolk-scorecard.yml b/.github/workflows/bytefolk-scorecard.yml new file mode 100644 index 0000000..bca8aa4 --- /dev/null +++ b/.github/workflows/bytefolk-scorecard.yml @@ -0,0 +1,49 @@ +name: ByteFolk Scorecard + +on: + push: + branches: + - main + schedule: + - cron: '43 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + scorecard: + name: OpenSSF Scorecard + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + actions: read + pull-requests: read + security-events: write + id-token: write + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Run Scorecard analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload Scorecard artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scorecard-results + path: results.sarif + if-no-files-found: error + retention-days: 14 + + - name: Upload Scorecard results + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/bytefolk-security.yml b/.github/workflows/bytefolk-security.yml new file mode 100644 index 0000000..88c2e66 --- /dev/null +++ b/.github/workflows/bytefolk-security.yml @@ -0,0 +1,70 @@ +name: ByteFolk Security Baseline + +on: + pull_request: + push: + branches: + - main + schedule: + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency review + if: ${{ github.event_name == 'pull_request' }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + fail-on-scopes: runtime + comment-summary-in-pr: never + + codeql: + name: CodeQL (${{ matrix.language }}) + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + actions: read + packages: read + security-events: write + strategy: + fail-fast: false + matrix: + language: + - go + - python + - javascript-typescript + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + with: + languages: ${{ matrix.language }} + queries: security-extended + + - name: Autobuild Go + if: ${{ matrix.language == 'go' }} + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + + - name: Analyze + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + with: + category: /language:${{ matrix.language }} From 87db0dfe0507be2190fe2fdcce0e267be8224f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:25:59 +0800 Subject: [PATCH 08/31] chore(web): refresh audited development dependencies (#192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Requirement / goal Refs #139 (CI baseline only; this PR does not expand the checkpoint bug scope). Restore the current `mem` Web audit gate by refreshing the lockfile entries with published fixes. ## Scope - Refresh only `web/package-lock.json`. - Resolve the current main Web audit findings for js-yaml, Vitest/@vitest/mocker, and postcss-selector-parser. - No application code, server behavior, transcript format, or checkpoint implementation changes. ## Validation ledger - PASS — `npm ci` - PASS — `npm run audit` (production and high-severity development gates) - PASS — `npm run lint` - PASS — `npm run typecheck` - PASS — `npm run build` - PASS — `git diff --check` ## Known limitations - This is a CI/security-baseline prerequisite for clean current-main PR validation, not the #139 product fix and not an issue-close claim. - The lockfile refresh is intentionally separate from [#191](https://github.com/bytefolk/mem/pull/191); #191 should be rebased or retargeted after this baseline lands. --- web/package-lock.json | 100 +++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 103ae42..28a95f0 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -2161,16 +2161,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2179,13 +2179,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2206,9 +2206,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2219,13 +2219,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2233,14 +2233,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2249,9 +2249,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2259,13 +2259,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3784,9 +3784,9 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -4847,9 +4847,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6009,19 +6009,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6049,12 +6049,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" From 39e8946f3391347cf9a3249b1f8cc161fdecd1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Thu, 10 Sep 2026 23:52:55 +0800 Subject: [PATCH 09/31] fix(npm): follow the registry identifier to the bytefolk namespace (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this changes `mcpName` becomes **`io.github.bytefolk/mem-mcp`** in `npm/server.json` and `npm/package.json`. This implements the registry half of the 2026-09-03 decision that G5 does not wait on an npm scope migration. The official MCP Registry derives an entry's namespace from the **repository owner**. The value on `main` still names `fullstack-ai-infra`, which this repository no longer is, so a submission carrying it either points into a namespace we do not control or is rejected by namespace validation. That single stale string is why the registry listing never appeared — reading `search=io.github.fullstack-ai-infra` and `search=bytefolk` against the public registry API both return `count: 0`, so there is no prior record to migrate over. ## What is deliberately not changed | | value | why it stays | | --- | --- | --- | | npm package name | `@fullstack-ai-infra/mem-mcp` | The decision keeps the published scope. A registry identifier and an npm package identifier are different namespaces; moving the package would break every existing `"args": ["-y", "@fullstack-ai-infra/mem-mcp"]` client config and every cached install for no registry-side gain. | | installer cache directory | `…/fullstack-ai-infra/mem-mcp` | Renaming it discards working caches to change a folder name. | | version | `0.1.1` | `scripts/validate_release_version.sh:39` pins `npm/server.json`'s version to the tag, so the bump belongs to the release commit, not here. | The resulting shape — npm scope `@fullstack-ai-infra`, registry namespace `io.github.bytefolk` — is intentional and is what the new test encodes, so it does not read as a half-finished migration. ## The guard The defect was a stale string in a manifest, and a rename is precisely the event that makes a manifest stale, so `npm/registry-identity.test.js` now asserts the identifier against the repository coordinate `npm/install.js` already downloads from. It derives the expected namespace rather than hardcoding `bytefolk`, so a future rename moves the assertion instead of breaking it. | run | result | | --- | --- | | this branch | 3/3 pass | | `mcpName` left on `fullstack-ai-infra` (the shipped state) | 2 of 3 fail | | repository coordinate renamed, identifier not followed | 1 of 3 fails | | full `npm test` | 38 tests, 37 pass, 1 pre-existing platform skip | | same on `main@1332bf46` | 35 tests, 34 pass, 1 skip | Added to the `test` script's file list, so it runs in the `npm-wrapper` CI job rather than only when named directly. ## Two things to know before reviewing - **`npm run test:tarball` fails on this branch and identically on `main`.** It is an `npm install --offline` of the locally packed tarball against this host's npm cache, not something this change touched. CI invokes it as `npx --yes npm@12.0.2 run test:tarball`, which this host cannot reproduce, so treat that leg as CI-covered only. - **#153 now contradicts this PR.** That issue asks to migrate the package to `@bytefolk/mem-mcp`; the decision is to keep the scope. I linked with `Refs #153`, not `Closes`, because only the registry half is done here. #153 should be rewritten, or the next person to pick it up will move the scope and undo the part that was never in question. ## Not in this PR Running `mcp-publisher` and the OAuth / organization verification are assigned to @PeterGuy326, and the registry record is created by that submission, not by merging this. `README.md:11` also still points its Smithery badge at `@fullstack-ai-infra/mem-mcp`; that slug needs verifying against what Smithery actually resolves before it is edited, so it is left alone here rather than repointed to something possibly equally wrong. Merging this PR is not a claim that it is merge-ready, and no review has been requested or recorded. Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com> --- CHANGELOG.md | 11 ++++++- npm/install.js | 1 + npm/package.json | 4 +-- npm/registry-identity.test.js | 60 +++++++++++++++++++++++++++++++++++ npm/server.json | 2 +- 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 npm/registry-identity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 104b02f..3e6107e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,16 @@ The project publishes 0.x prerelease versions; a stable release line is not yet - Migrate GitHub repository, Release, issue, badge, and raw-content coordinates to the canonical `bytefolk` organization while retaining the published npm - scope, MCP identity, and existing cache paths. + scope and the existing cache paths. +- Follow the registry identifier after that rename: `mcpName` becomes + `io.github.bytefolk/mem-mcp`, because the official MCP Registry namespace is + derived from the repository owner and the previous value, naming the + organization this repository used to belong to, cannot resolve. The npm + package name and the installer's cache directory are deliberately unchanged, + so an existing installation keeps working and keeps its cache. + `npm/registry-identity.test.js` now asserts the identifier against the + repository coordinate the installer itself uses, so the next rename cannot + leave a stale identifier behind unnoticed. ### Security diff --git a/npm/install.js b/npm/install.js index 55dcf41..4215cc7 100644 --- a/npm/install.js +++ b/npm/install.js @@ -792,6 +792,7 @@ module.exports = { isLockContention, openResponse, releaseAssetLock, + REPO, sha256File, verifyFile, }; diff --git a/npm/package.json b/npm/package.json index 9481628..235e2ee 100644 --- a/npm/package.json +++ b/npm/package.json @@ -2,7 +2,7 @@ "name": "@fullstack-ai-infra/mem-mcp", "version": "0.1.1", "description": "MCP server for mem — a portable, self-hosted memory plane for AI agents", - "mcpName": "io.github.fullstack-ai-infra/mem-mcp", + "mcpName": "io.github.bytefolk/mem-mcp", "keywords": [ "mcp", "model-context-protocol", @@ -27,7 +27,7 @@ "os": ["linux", "darwin", "win32"], "cpu": ["x64", "arm64"], "scripts": { - "test": "node --test install.test.js mem-mcp.test.js windows-shim.test.js", + "test": "node --test install.test.js mem-mcp.test.js registry-identity.test.js windows-shim.test.js", "test:tarball": "node --test clean-tarball.test.js" }, "files": [ diff --git a/npm/registry-identity.test.js b/npm/registry-identity.test.js new file mode 100644 index 0000000..298e161 --- /dev/null +++ b/npm/registry-identity.test.js @@ -0,0 +1,60 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const test = require("node:test"); +const { REPO } = require("./install"); + +const serverManifest = JSON.parse( + readFileSync(join(__dirname, "server.json"), "utf8"), +); +const packageManifest = JSON.parse( + readFileSync(join(__dirname, "package.json"), "utf8"), +); + +// The registry derives its namespace from the repository owner, so every rename +// leaves the published identifier pointing at an organization that no longer +// exists, and the submission is rejected with no hint that a stale string in a +// manifest caused it. The repository coordinate is therefore asserted here +// against the identifier the submission carries, rather than repeated in a +// third file. +test("the registry namespace follows the repository owner", () => { + const [owner] = REPO.split("/"); + assert.match(REPO, /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\/[a-z0-9._-]+$/i); + for (const [label, manifest] of [ + ["server.json", serverManifest], + ["package.json", packageManifest], + ]) { + assert.equal( + manifest.mcpName, + `io.github.${owner}/${serverManifest.name}`, + `${label} mcpName must carry the owner of the repository the installer downloads from (${REPO})`, + ); + } +}); + +// Both manifests are published and read by different tools, so a disagreement +// between them decides which one the validator saw rather than which one is right. +test("both manifests name the same server", () => { + assert.equal(serverManifest.mcpName, packageManifest.mcpName); + assert.equal(serverManifest.version, packageManifest.version); +}); + +// A registry identifier is a primary key, so the npm scope is allowed to differ +// from it while the package name is not allowed to drift from it: `mcpName` +// ends in the unscoped package name by construction, and moving the package +// without moving the identifier would silently fork the registry record. +test("the registry name is the unscoped package name", () => { + const [, packageName] = packageManifest.name.split("/"); + assert.equal( + serverManifest.mcpName.split("/").pop(), + packageName, + "the trailing segment of mcpName must be the published package name without its scope", + ); + assert.equal( + serverManifest.name, + packageName, + "server.json name must match the published package name without its scope", + ); +}); diff --git a/npm/server.json b/npm/server.json index f214f7a..aa3fd6e 100644 --- a/npm/server.json +++ b/npm/server.json @@ -1,5 +1,5 @@ { - "mcpName": "io.github.fullstack-ai-infra/mem-mcp", + "mcpName": "io.github.bytefolk/mem-mcp", "name": "mem-mcp", "version": "0.1.1", "description": "MCP server for mem — a portable, self-hosted memory plane for AI agents", From a1436d2d19e2c08762ebd8ee42df95be9ec2b043 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:05:43 +0800 Subject: [PATCH 10/31] build(deps-dev): bump postcss-selector-parser from 6.1.2 to 6.1.4 in /web (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 6.1.2 to 6.1.4.
Release notes

Sourced from postcss-selector-parser's releases.

6.1.4

  • fix: tolerate non-node children when serializing selectors

6.1.3

Changelog

Sourced from postcss-selector-parser's changelog.

Changelog of postcss-selector-parser

7.1.5 - 2026-08-07

  • fix: don't treat a non-prefix token before | as a namespace (#324 by @​spokodev)
  • fix: preserve whitespace before a * namespace in attribute selectors (#325 by @​spokodev)
  • fix: TypeError on unclosed [, ( and trailing | (#330 by @​theRizwan)

7.1.4 - 2026-06-11

  • fix: tolerate non-node children when serializing selectors

7.1.3 - 2026-06-11

  • Improve fix CVE-2026-9358 (NVD) / SNYK-JS-POSTCSSSELECTORPARSER-16873882 (clone/walk)

7.1.2 - 2026-06-09

7.1.1

  • perf: replace startsWith with strict equality (#308)
  • fix(types): add walkUniversal declaration (#311)

7.1.0

  • feat: insert(Before|After) support multiple new node

7.0.0

  • Feat: make insertions during iteration safe (major)
Commits
  • 4a7e4e3 7.1.4
  • e2021c5 fix: tolerate non-node children when serializing selectors
  • 7893b74 7.1.3
  • 5bc698c Improve fix CVE-2026-9358 (NVD) / SNYK-JS-POSTCSSSELECTORPARSER-16873882 (clo...
  • db32327 run oxfmt
  • 16e2581 simplify deps (#319)
  • b185e20 Add description in package.json + full repo url
  • de415f1 Add Tidelift security notice
  • c4f2c8c CI: make Node 14 test job lockfile-v3 compatible (#318)
  • 4e93663 Fix test run on node < 20
  • Additional commits viewable in compare view
Maintainer changes

This version was pushed to npm by moox, a new releaser for postcss-selector-parser since your current version.


Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com> From d9d0969deae252f3619429e8eb07aa2533e26e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Fri, 11 Sep 2026 00:14:25 +0800 Subject: [PATCH 11/31] fix(cli-memd): withhold URLs whose credentials cannot be attributed (#165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #112 ## Summary A configured URL that carries credentials in a shape `url.Parse` does not report as **userinfo** reaches output today. This adds one shared gate that redacts such a value when it can prove it is a transport URL, and **withholds the value whole** when it cannot, and wires it into all three egresses R3 names: the API client, `memd`'s log lines, and `mem doctor`. The adjudicated direction this implements is recorded on #112 as R3 (comment 5536659086): gate on *"parses as a recognized transport scheme with userinfo redacted, otherwise withhold the whole value"*. ## Scope: all three named egresses R3 names three egresses — doctor, apiclient, `memd`'s log line. All three are covered here. | egress | on `main` at the base | covered here | | --- | --- | --- | | apiclient — request construction | leaks | gated | | apiclient — all 4 `http.Client.Do` sites | leaks, ungated entirely | gated, one test case per site | | apiclient — `newRequest` on the workspace-transfer path | leaks | gated (arrived with #164's `workspace_transfer.go`) | | `memd` startup log line | leaks (`User != nil` gate) | gated | | `memd` fatal log line | leaks via a third-party error | gated | | `mem doctor` (text + JSON) | not present | gated through the same package | ### How doctor got onto this branch `mem doctor` is not on `main`; it only existed on #164 (which sits on #131's original doctor commit). Because #131 was closed as superseded by #164, doctor had exactly one carrier, so the surviving branch had to absorb it before #164 could be closed. Rather than re-author it, this branch **merges** the work: ``` c9e0e63 docs: correct doctor's stated secret guarantee (this change) d30f1ff fix(cli): route mem doctor's URL reporting through the shared gate (this change) ad4e78f Merge origin/main d7f2dcb Merge #164 (mem doctor) into the credential-url-gate branch b229537 fix(client,memd): withhold URLs whose credentials cannot be attributed fffcd4c fix(cli): redact malformed URLs and request build errors ← #164, author PeterGuy326 46499b2 feat(cli): add mem doctor and first-run guidance ← #131, author waterbro-8 ``` Both earlier commits are **ancestors** of this head, so #164's and #131's authorship is preserved on the merge rather than claimed as mine. #164's unrelated per-probe `--timeout` fix and its `workspace_transfer.go` coverage came across with the merge and are kept. What `d30f1ff` itself does is replace doctor's two local string-level helpers with calls into `internal/redact`, so there is one policy rather than three: ```go func redactURL(raw string) string { return redact.URL(raw, redact.APIURLs) } func sanitizeProbeError(err error) string { return redact.TransportError(err, redact.APIURLs) } ``` `c9e0e63` is prose only. It corrects three statements — in `CHANGELOG.md`, `docs/DEPLOYMENT.md` and a comment in `cmds_doctor.go` — that asserted the doctor report contains **no secret value**. That was never true of the query-parameter shape described under Known gap, measured at 3 sentinel occurrences per output format on this head. The non-goal stands; the promise was the thing that was wrong, so the docs now name the residual instead of denying it. ## The shape, and why the previous gate missed it ``` url.Parse("admin:pw@host") → Scheme="admin" Opaque="pw@host" User=nil ``` A gate written as `if parsed.User == nil { return raw }` therefore echoes the credential verbatim — not as a corner case, but as the normal path for any value whose scheme happens to contain a colon. The gate also refuses to scrub error *text*, because that cannot be made tight: `url.Error` renders with `%q`, so a `"` inside a password arrives escaped and a scanner that pairs quotes mis-pairs, replaces nothing, and leaves its cursor inside the URL. Withholding is chosen over partial trimming for the same reason: a delimiter inside a credential splits the message into pieces that no longer look like a URL, and the piece without the `@` is exactly the half that leaked. `memd`'s fatal line is included because `queue.NewClient` wraps asynq's parse error, which embeds the whole DSN: ``` queue: parse redis url: asynq: could not parse redis uri: parse "redis://:PASS@ho st:6379/0": invalid character " " in host name ``` slog renders an error value as its text, so that reaches the log unmodified. ## Known gap, stated rather than smoothed over The gate proves the absence of **userinfo**, not of every credential. A secret supplied as a query parameter (`redis://host:6379/?password=x`) parses as a clean URL with `User == nil` and **is still echoed**. This is the adjudicated scope, not an oversight, and closing it is a separate decision. It is pinned by a named characterization test (`TestTextKnownGapQueryParameterCredentialsAreEchoed`) that fails if someone closes the gap without updating the expectation, so the residual cannot become folklore. Withholding also has a real diagnosability cost, accepted by design: the reason a request failed is still reported, the host is not. ``` Error: Get [withheld]: unsupported protocol scheme "admin" ``` ## Validation ledger Evidence level claimed: **E3 — independently reproduced**. Not E4: I am the author, and E4 requires a non-author to verify the acceptance criteria end to end. Environment: Linux x64, Go 1.25.0, exact tree `9fa9c14de074b4ddb5320d4f83803f1657918b34` — `HEAD^{tree}` of the current head `c9e0e63`, read back from `GET /repos/bytefolk/mem/commits/c9e0e63…` and equal to it byte-for-byte, so the tree measured here and the tree on the PR are the same object. (This head was pushed through the Git Data API because `github.com:443` was down on this box; the local commit sha differs from the remote one, the tree does not.) | Check | Result | | --- | --- | | `go build ./...` | pass | | `go vet ./...` | pass | | `gofmt -l .` | no output | | `go test -count=1 ./...` | 32 packages ok, 0 FAIL | | `cmd/mem` (includes 16 `mem doctor` tests) | 102 tests, 0 fail | | `internal/redact` | 30 tests, 0 fail | | `internal/apiclient` | 45 tests, 0 fail | | `cmd/memd` | 15 tests, 0 fail | | `git merge-base --is-ancestor origin/main HEAD` | yes (main not silently reverted) | | CI on this head | **15/15 check names `success`** on `c9e0e63` (`run_attempt` 1), including `Go` and `PostgreSQL integration`; also 15/15 on the immediately preceding `d30f1ff` | ### `mem doctor` end-to-end, measured on built binaries Seven malformed credential URL shapes, each run through `mem doctor --server --timeout 1s` in **both** `text` and `json`, counting occurrences of a sentinel password in everything the process wrote: | # | shape | #164 head `fffcd4c` | this head `c9e0e63` | | --- | --- | --- | --- | | 1 | `http://admin:PW@127.0.0.1:1` | 0 | 0 | | 2 | `http://admin:PW@127.0.0.1:%zz` | 0 | 0 | | 3 | `http://admin:PW@ho st.example.com` | 0 | 0 | | 4 | `http://admin:PW x@127.0.0.1:1` | 0 | 0 | | 5 | `http://admin:PW@%` | 0 | 0 | | 6 | `http://admin:PW@mem.internal:99999999` | 0 | 0 | | 7 | `admin:PW@mem.internal:8787` (no scheme) | **6** | **0** | Shape 7 is the `url.Parse` → `User=nil` case from the section above: #164 prints the configured URL three times (`server`, `detail`, `hint`) in each of the two formats, so 6 occurrences is one leak per field. **A zero from a binary that has no `doctor` command means nothing.** This table was first produced against a build of `b229537` and came back 0/0/0/0/0/0/0, which looked like a pass — the actual output was `Error: unknown command "doctor" for "mem"`, because that head had no doctor at all. The harness now counts `unknown command` alongside `unknown flag` and every row above was taken with both counters at 0. ### Mutation controls Run against a copy, with the mutation confirmed present in the source before the suite is trusted: 1. Reverting the gate to the pre-fix `User != nil` form turns **10 named cases red across 3 packages** (`internal/redact`, `cmd/memd`, `cmd/mem`). 2. Removing the gate at **one** apiclient `Do` site turns red **exactly that site's** test case and leaves the other three green. The four cases map one-to-one onto the four sites, so a regression at a single site cannot hide. Control 2 was found by this method — an earlier version of this port left one site raw and the table caught it. 3. Putting **#164's original `redactURL` / `sanitizeProbeError` back** into this tree — i.e. merging #164 and *not* rewiring doctor — fails **exactly one test**, `TestDoctorSchemelessServerURLDoesNotLeakCredentials`, and its built binary leaks 2 sentinel occurrences on shape 7. Before that test was added, the same mutation failed **zero** tests. So the merge on its own was unprotected, and the one new test is what closes that. ### Two pre-existing assertions were relaxed — named, because one is not mine The gate withholds a value it cannot prove is a transport URL, where #164's local helper echoed a partially cleaned one. Two assertions demanded the echo shape specifically, so they were widened to accept **redact *or* withhold**: | location | test | author of the assertion as written | | --- | --- | --- | | `cmds_doctor_test.go:418` | `TestRedactURLStripsUserinfo` | waterbro-8 (#131) | | `cmds_doctor_test.go:456` | `TestDoctorMalformedServerURLDoesNotLeakCredentials` | **PeterGuy326 (#164)** | Both keep their original first clause — the secret must not appear, and a cleaned URL must still carry `REDACTED@`. Only the *form* of an acceptable answer widened. Concretely at `:418`, `strings.Contains(got, secret)` still fails the test, and the `REDACTED@mem.internal:8787` check two lines above it is untouched. **No assertion was deleted, and the count went up, not down**: #164's 15 doctor tests are all present on this head (0 dropped), plus `TestRedactURLStripsUserinfo` and the new `TestDoctorSchemelessServerURLDoesNotLeakCredentials` = **16**. The way to check this claim is mutation control 3 above: if the widening had quietly weakened coverage, restoring #164's scrubber would have gone green, and instead it fails a test. Anyone who considers the withhold form unacceptable for `:456` should say so on #164's thread rather than assume I settled it unilaterally — I changed an assertion another author wrote to pass my gate. ## What I did not verify - The leak table was rebuilt and re-run against `c9e0e63` after the docs commit; that commit is prose and comments only, and the result is unchanged (0 on all seven shapes). Earlier heads `d30f1ff` and `b229537` each had 15/15 CI at the time they were written. - **There is no prior CI baseline for the doctor half to compare against.** #164's head `fffcd4c` has 0 `check-runs` and no workflow run has ever been recorded for it, so doctor has never been green in CI anywhere — including here, where it is now inside the `Go` job for the first time. That is a gain, not a regression, but it does mean no one has seen this code pass CI before. - Nothing was exercised on Windows or macOS. All results are Linux x64. - `memd` was **not** driven end-to-end to its fatal redis path. Reaching it requires a reachable PostgreSQL (the queue client is constructed after `db Open`), and the only local server on 5432 is an unrelated instance whose credentials I do not have — my attempt failed at `db open: ... failed SASL auth` before touching the queue. That egress is therefore evidenced by executing the gate on the asynq error string **measured from the real library in this tree**, not by a live `memd` run. - No claim is made about `#112`'s exit-code contract, R2/R3 acceptance, or #131/#164 merge readiness. ## Non-goals - No change to query-string credential handling (see Known gap). - No change to `#164`'s or `#131`'s branches; nothing here is pushed to anybody else's ref. #164 is pulled *into* this branch by merge, which is why #164 can be closed without losing its work. - No re-adjudication of R3. Doctor's `--timeout` behaviour, its check set and its JSON schema are #164's, unchanged except where they called the old local helpers. This PR stays `Refs` on #112 rather than `Closes`, because merging it would not by itself settle #112's acceptance — that needs a reviewer's decision, not just a diff landing. **Review strength of this PR, stated plainly:** 15/15 checks pass on `c9e0e63`, and I am the author of `b229537`, `d30f1ff` and `c9e0e63` — automated checks and my own sign-off are not a review. What this PR is missing is exactly one independent human review; nothing else blocks it (`mergeable=true`, no conflict with `main`, which is an ancestor of this head). It needs one human approval, and I am not that reviewer — on the doctor half especially, since I edited an assertion PeterGuy326 wrote. --------- Co-authored-by: waterbro-8 Co-authored-by: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com> Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> --- CHANGELOG.md | 38 + docs/DEPLOYMENT.md | 36 + docs/schemas/mem-doctor.v1.schema.json | 84 ++ server/cmd/mem/client.go | 19 + server/cmd/mem/cmds_auth.go | 4 +- server/cmd/mem/cmds_auth_test.go | 13 +- server/cmd/mem/cmds_context.go | 2 +- server/cmd/mem/cmds_doctor.go | 377 +++++++++ server/cmd/mem/cmds_doctor_test.go | 748 ++++++++++++++++++ server/cmd/mem/cmds_face.go | 2 +- server/cmd/mem/cmds_file.go | 6 +- server/cmd/mem/cmds_file_annotations.go | 2 +- server/cmd/mem/cmds_folder.go | 2 +- server/cmd/mem/cmds_handoff.go | 4 +- server/cmd/mem/cmds_ingest.go | 2 +- server/cmd/mem/cmds_memory.go | 4 +- server/cmd/mem/cmds_model.go | 2 +- server/cmd/mem/cmds_profile.go | 2 +- server/cmd/mem/cmds_provider.go | 8 +- server/cmd/mem/cmds_related.go | 4 +- server/cmd/mem/cmds_remember.go | 2 +- server/cmd/mem/cmds_search.go | 2 +- server/cmd/mem/cmds_timeline.go | 2 +- server/cmd/mem/config.go | 13 + server/cmd/mem/main.go | 12 + .../mem/testdata/doctor_healthy.golden.json | 34 + server/cmd/memd/main.go | 18 +- server/cmd/memd/main_test.go | 51 +- server/internal/apiclient/apiclient.go | 69 +- server/internal/apiclient/apiclient_test.go | 60 ++ .../internal/apiclient/workspace_transfer.go | 4 +- server/internal/redact/redact.go | 182 +++++ server/internal/redact/redact_test.go | 291 +++++++ 33 files changed, 2044 insertions(+), 55 deletions(-) create mode 100644 docs/schemas/mem-doctor.v1.schema.json create mode 100644 server/cmd/mem/cmds_doctor.go create mode 100644 server/cmd/mem/cmds_doctor_test.go create mode 100644 server/cmd/mem/testdata/doctor_healthy.golden.json create mode 100644 server/internal/redact/redact.go create mode 100644 server/internal/redact/redact_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e6107e..7bdac57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ## [Unreleased] +### Added + +- `mem doctor` — a read-only diagnosis of why the CLI cannot talk to a working + server (`#112`). It reports four checks in a fixed order: reachability of the + configured server URL, whether a credential exists, the workspace the server + resolved for that credential, and CLI/server version skew. Each finding carries + the SPEC §7.1 exit code it contributes (`0` ok · `2` not_found · `3` auth · + `4` plan/quota · `5` provider/timeout), and a check that an earlier failure made + impossible is reported as `skipped` instead of guessed. It issues only `GET` + requests and never writes configuration, starts a container, or installs a + dependency; `--format json` emits the `mem.doctor` v1 document described by + `docs/schemas/mem-doctor.v1.schema.json`. A token is described only by where it + came from, and a configured URL has its userinfo and its query parameter values + replaced by `REDACTED` — a credential in a query parameter is the shape pgx + accepts as a real password — or, when the URL cannot be proven to be a + credential-free transport URL, is withheld whole. See `docs/DEPLOYMENT.md`. +- First-run guidance: a command that fails because no credential exists now says + so on a machine with no configuration at all by naming the documented + deployment path (`deploy/compose`, `docs/DEPLOYMENT.md`), instead of telling + somebody to log in against a server that is not running yet. Hosts that already + have a configuration keep the previous, shorter hint. + ### Changed - Migrate GitHub repository, Release, issue, badge, and raw-content coordinates @@ -53,6 +75,22 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- A configured URL that carries credentials in a shape `url.Parse` does not + report as userinfo no longer reaches output. `admin:pw@host` parses as + `Scheme="admin"` with the credential in `Opaque` and `User` unset, so an + implementation that gates on `User != nil` echoes it verbatim. On this base it + leaked from the CLI API client — at request construction and at all four + `http.Client.Do` sites, which the previous error path did not cover — and from + `memd`'s startup log line and its fatal log line, the last of which additionally + carries third-party errors that embed a whole DSN. Both now route through one + shared gate that redacts a value it can prove is a transport URL and + **withholds the value whole** otherwise. It does not scrub credentials out of + error text, which cannot be made tight: `url.Error` renders with `%q`, so a + quote inside a password arrives escaped and a scanner that pairs quotes + mis-pairs and replaces nothing. Withholding costs some diagnosability by design; + why a request failed is still reported, and a DSN still names the parameters it + sets — every query parameter *value* is replaced by `REDACTED`, including + `?password=`, which pgx honours as the real password. - The npm installer no longer aborts a concurrent first run on Windows. The per-asset cache lock previously treated only `EEXIST` as contention, but a contended `mkdir` on Windows may raise `EPERM` or `EACCES`, so a process diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 801e41d..25d0b57 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -163,6 +163,42 @@ docker compose --env-file .env -f compose.yaml ps docker compose --env-file .env -f compose.yaml logs --tail=200 migrate memd worker web ``` +### Diagnose from the client side + +`mem doctor` answers the client half of the same question: why the CLI cannot +reach a working server. It issues only `GET` requests, and it never writes +configuration, starts or stops a container, or installs a dependency — a failed +diagnosis changes nothing on the machine. + +```bash +mem doctor +mem doctor --format json +``` + +It reports four checks in a fixed order and stops guessing after the first +failure: reachability of the configured server URL (`/healthz`, probed without a +credential so a bad token is not misread as an outage), whether a credential +exists, the workspace the server resolved for that credential +(`/v1/capabilities`), and CLI/server version skew (`/v1/version`). A check that +an earlier failure made impossible is reported as `skipped`, naming the blocking +check, rather than as an inferred pass. + +The process exits with the first failing check's SPEC §7.1 code — `0` ok · +`2` not_found · `3` auth · `4` plan/quota · `5` provider/timeout — so a wrapper +can branch on it. Version skew is advisory and contributes `0`; it is also not +computable in builds that do not inject a CLI version, which today includes +release builds, so the check reports that limit instead of claiming agreement. + +`--format json` emits the `mem.doctor` v1 document validated by +[`schemas/mem-doctor.v1.schema.json`](schemas/mem-doctor.v1.schema.json), and a +token is described only by where it came from. For a configured URL, userinfo and +every query parameter **value** are replaced by `REDACTED` — the parameter names +survive so the report still says which settings are on — and a URL that cannot be +proven to be a credential-free transport URL is withheld whole as `[withheld]` +rather than partially trimmed. A secret supplied as a query parameter +(`http://mem.internal:8787?password=…`) is therefore not reported, which matters +because pgx accepts `postgres://host/db?password=…` as the real password. + ### First account and login The default `MEM_REGISTRATION_MODE=first_user` atomically allows exactly one diff --git a/docs/schemas/mem-doctor.v1.schema.json b/docs/schemas/mem-doctor.v1.schema.json new file mode 100644 index 0000000..3f8dd30 --- /dev/null +++ b/docs/schemas/mem-doctor.v1.schema.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://getmem.dev/schemas/mem-doctor.v1.schema.json", + "title": "mem doctor report v1", + "description": "Read-only diagnosis emitted by `mem doctor --format json`. Mirrors doctorReport and doctorCheck in server/cmd/mem/cmds_doctor.go. Contains no credential, token or DSN value: the server URL is reported with userinfo removed.", + "type": "object", + "additionalProperties": false, + "required": [ + "contract", + "schema_version", + "server", + "cli_version", + "exit_code", + "checks" + ], + "properties": { + "contract": { + "const": "mem.doctor" + }, + "schema_version": { + "const": 1 + }, + "server": { + "type": "string", + "description": "Configured memd base URL, userinfo redacted.", + "minLength": 1 + }, + "cli_version": { + "type": "string", + "description": "Version this CLI build reports; \"dev\" when none was injected at build time." + }, + "server_version": { + "type": "string", + "description": "Version the server reported. Absent when the version probe did not run." + }, + "exit_code": { + "type": "integer", + "description": "SPEC 7.1 process exit code: first failing check's code, else 0.", + "enum": [0, 2, 3, 4, 5] + }, + "checks": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "description": "Fixed ordered list, never a wizard: server_reachability, credential, workspace, version_skew.", + "prefixItems": [ + { "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "server_reachability" } } } ] }, + { "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "credential" } } } ] }, + { "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "workspace" } } } ] }, + { "allOf": [ { "$ref": "#/$defs/check" }, { "properties": { "name": { "const": "version_skew" } } } ] } + ], + "items": { "$ref": "#/$defs/check" } + } + }, + "$defs": { + "check": { + "type": "object", + "additionalProperties": false, + "required": ["name", "status", "exit_code", "detail"], + "properties": { + "name": { + "enum": ["server_reachability", "credential", "workspace", "version_skew"] + }, + "status": { + "enum": ["ok", "warn", "fail", "skipped"], + "description": "\"skipped\" means an earlier failure made this check unrunnable; it is never an inferred pass." + }, + "exit_code": { + "type": "integer", + "description": "This finding's contribution to the process exit code. Advisory and skipped findings contribute 0.", + "enum": [0, 2, 3, 4, 5] + }, + "detail": { + "type": "string", + "minLength": 1 + }, + "hint": { + "type": "string", + "description": "Actionable next step. First-run hints name the documented container path." + } + } + } + } +} diff --git a/server/cmd/mem/client.go b/server/cmd/mem/client.go index cda2fd4..ee8625c 100644 --- a/server/cmd/mem/client.go +++ b/server/cmd/mem/client.go @@ -24,6 +24,25 @@ func newCliError(code int, msg, hint string) *cliError { return &cliError{code: code, msg: msg, hint: hint} } +// notLoggedInHint is the credential guidance that always applies. +const notLoggedInHint = "run `mem auth login` first" + +// firstRunDeployHint names the documented deployment path rather than a +// host-specific install recipe, so a machine that has never been configured is +// not sent off to build the bare-metal stack by hand. +const firstRunDeployHint = "no server configured yet — the documented path is deploy/compose, see docs/DEPLOYMENT.md" + +// errNotLoggedIn is the one fail-closed auth error for commands that need a +// credential. When no config file exists at all, the run is a first run: the +// hint additionally names the documented deployment path, because telling +// somebody to log in against a server that does not exist yet is not guidance. +func errNotLoggedIn() error { + if configFileExists() { + return newCliError(3, "not logged in", notLoggedInHint) + } + return newCliError(3, "not logged in", notLoggedInHint+"; "+firstRunDeployHint) +} + // fromAPIError maps an *apiclient.APIError to a *cliError with the SPEC §7.1 // exit code. Any other error is returned unchanged. func fromAPIError(err error) error { diff --git a/server/cmd/mem/cmds_auth.go b/server/cmd/mem/cmds_auth.go index 8631a17..5377d02 100644 --- a/server/cmd/mem/cmds_auth.go +++ b/server/cmd/mem/cmds_auth.go @@ -132,7 +132,7 @@ func newAuthStatusCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var capabilities struct { @@ -241,7 +241,7 @@ func newTokenCreateCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } scopeList := splitCommas(scopes) body := map[string]any{ diff --git a/server/cmd/mem/cmds_auth_test.go b/server/cmd/mem/cmds_auth_test.go index 551cf49..46a3865 100644 --- a/server/cmd/mem/cmds_auth_test.go +++ b/server/cmd/mem/cmds_auth_test.go @@ -162,8 +162,17 @@ func TestAuthStatusWithoutTokenReturnsAuthExitCode(t *testing.T) { if !errors.As(err, &cliErr) { t.Fatalf("error type = %T, want *cliError", err) } - if cliErr.code != 3 || cliErr.hint != "run `mem auth login` first" { - t.Fatalf("cli error = %#v", cliErr) + // #112 REQ-002 changed this hint's text for a host with no config file at + // all, so the old exact-equality assertion is intentionally widened: the + // login step must stay, and the documented deployment path must now appear. + if cliErr.code != 3 { + t.Fatalf("cli error code = %d, want 3 (%#v)", cliErr.code, cliErr) + } + if !strings.HasPrefix(cliErr.hint, "run `mem auth login` first") { + t.Errorf("hint = %q, want it to keep the login step", cliErr.hint) + } + if !strings.Contains(cliErr.hint, "deploy/compose") || !strings.Contains(cliErr.hint, "docs/DEPLOYMENT.md") { + t.Errorf("hint = %q, want first-run guidance naming the documented path", cliErr.hint) } } diff --git a/server/cmd/mem/cmds_context.go b/server/cmd/mem/cmds_context.go index 45eaaa7..02cc2ce 100644 --- a/server/cmd/mem/cmds_context.go +++ b/server/cmd/mem/cmds_context.go @@ -82,7 +82,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } body := map[string]any{"query": strings.Join(args, " ")} if scope != "" { diff --git a/server/cmd/mem/cmds_doctor.go b/server/cmd/mem/cmds_doctor.go new file mode 100644 index 0000000..e7f1566 --- /dev/null +++ b/server/cmd/mem/cmds_doctor.go @@ -0,0 +1,377 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/PeterGuy326/mem/server/internal/apiclient" + "github.com/PeterGuy326/mem/server/internal/redact" + "github.com/spf13/cobra" +) + +// mem doctor is a read-only diagnosis surface (issue #112). It exists because a +// first-time user's most common failure is a missing prerequisite they cannot +// name: nothing listening at the configured URL, no credential, no workspace. +// Before this command the only signal was a per-command error. +// +// Contract, in the order the checks run: +// +// server_reachability GET /healthz with no credential +// credential is a token configured at all (no request) +// workspace GET /v1/capabilities +// version_skew GET /v1/version +// +// REQ-003 keeps this strictly diagnostic: every request is a GET, nothing is +// created, no dependency is installed, no Docker or compose command is issued. +// URLs pass through internal/redact on the way out: userinfo is redacted, query +// parameter values are blanked, and a URL that cannot be proven credential-free +// is withheld whole. + +// doctorContract and doctorSchemaVersion follow the repo convention of naming a +// machine-readable surface and versioning it, mirroring docs/schemas. +const ( + doctorContract = "mem.doctor" + doctorSchemaVersion = 1 +) + +// Statuses are a closed set. "skipped" is explicit: a check that could not run +// because an earlier one failed says so, instead of reporting an OK it did not +// earn or a failure it did not observe. +const ( + doctorOK = "ok" + doctorWarn = "warn" + doctorFail = "fail" + doctorSkipped = "skipped" +) + +// exit codes, per SPEC §7.1: 0 ok · 2 not_found · 3 auth · 4 plan/quota · +// 5 provider/timeout. +const ( + exitOK = 0 + exitNotFound = 2 + exitAuth = 3 + exitPlanQuota = 4 + exitProvider = 5 +) + +type doctorCheck struct { + Name string `json:"name"` + Status string `json:"status"` + // ExitCode is this finding's contribution to the process exit status. + // Advisory findings contribute 0. + ExitCode int `json:"exit_code"` + Detail string `json:"detail"` + Hint string `json:"hint,omitempty"` +} + +type doctorReport struct { + Contract string `json:"contract"` + SchemaVersion int `json:"schema_version"` + Server string `json:"server"` + CLIVersion string `json:"cli_version"` + ServerVersion string `json:"server_version,omitempty"` + ExitCode int `json:"exit_code"` + Checks []doctorCheck `json:"checks"` +} + +func newDoctorCmd() *cobra.Command { + var timeout time.Duration + cmd := &cobra.Command{ + Use: "doctor", + Short: "Diagnose local configuration and server connectivity (read-only)", + Long: `Report why the CLI cannot talk to a working mem server. + +doctor issues only GET requests and writes nothing: no token, no file, no +container and no configuration. It checks, in order, reachability of the +configured server URL, whether a credential exists, which workspace the server +resolved for that credential, and CLI/server version skew. Each finding carries +the SPEC §7.1 exit code it contributes (0 ok · 2 not_found · 3 auth · +4 plan/quota · 5 provider/timeout); the process exits with the first failing +check's code. + +A check that an earlier failure made impossible is reported as "skipped" rather +than guessed. + +Example: + mem doctor + mem doctor --format json + mem doctor --server http://localhost:8787 --timeout 2s`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + format, err := rememberOutputFormat(cmd) + if err != nil { + return err + } + report, err := runDoctor(cmd, timeout) + if err != nil { + return err + } + if format == "json" { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + if err := enc.Encode(report); err != nil { + return err + } + } else { + printDoctorReport(cmd, report) + } + if report.ExitCode != exitOK { + f := report.firstFailed() + return newCliError(report.ExitCode, "doctor: "+f.Name+" failed", f.Detail) + } + return nil + }, + } + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "per-request budget for the read-only probes") + return cmd +} + +func (r doctorReport) firstFailed() doctorCheck { + for _, c := range r.Checks { + if c.Status == doctorFail { + return c + } + } + return doctorCheck{Name: "doctor", Detail: "a check failed"} +} + +func runDoctor(cmd *cobra.Command, timeout time.Duration) (doctorReport, error) { + if timeout <= 0 { + return doctorReport{}, newCliError(1, "--timeout must be positive", "") + } + cfg, err := resolveConfig("") + if err != nil { + return doctorReport{}, err + } + report := doctorReport{ + Contract: doctorContract, + SchemaVersion: doctorSchemaVersion, + Server: redactURL(cfg.Server), + CLIVersion: cliVersion, + } + + reachCtx, cancel := context.WithTimeout(cmd.Context(), timeout) + reach := probeReachability(reachCtx, cfg.Server) + cancel() + report.Checks = append(report.Checks, reach) + + cred := probeCredential(cfg) + report.Checks = append(report.Checks, cred) + + // The remaining checks need a live, authenticated connection. Reporting a + // fabricated result for them would be the exact failure mode this command + // exists to remove. + var ws, ver doctorCheck + switch { + case reach.Status == doctorFail: + ws, ver = skippedCheck("workspace", reach.Name), skippedCheck("version_skew", reach.Name) + case cred.Status == doctorFail: + ws, ver = skippedCheck("workspace", cred.Name), skippedCheck("version_skew", cred.Name) + default: + wsCtx, wsCancel := context.WithTimeout(cmd.Context(), timeout) + ws = probeWorkspace(wsCtx, cfg) + wsCancel() + + verCtx, verCancel := context.WithTimeout(cmd.Context(), timeout) + ver = probeVersion(verCtx, cfg.Server, &report) + verCancel() + } + report.Checks = append(report.Checks, ws, ver) + + for _, c := range report.Checks { + if c.Status == doctorFail { + report.ExitCode = c.ExitCode + break + } + } + return report, nil +} + +// skippedCheck records a check that an earlier failure made impossible, and +// names the blocker so the text report stays actionable without the JSON. +func skippedCheck(name, blockedBy string) doctorCheck { + return doctorCheck{ + Name: name, + Status: doctorSkipped, + Detail: "not evaluated: " + blockedBy + " is failing", + } +} + +func probeReachability(ctx context.Context, server string) doctorCheck { + check := doctorCheck{Name: "server_reachability"} + // An unauthenticated probe: a 401 here would otherwise be read as "the + // server is down" by a user whose only problem is a bad token. + c := apiclient.New(server, "") + var resp struct { + OK bool `json:"ok"` + } + if err := c.DoJSON(ctx, http.MethodGet, "/healthz", nil, &resp); err != nil { + check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err) + return check + } + if !resp.OK { + check.Status = doctorFail + check.ExitCode = exitProvider + check.Detail = "healthz answered without ok:true" + check.Hint = deployPathHint() + return check + } + check.Status = doctorOK + check.Detail = "healthz ok at " + redactURL(server) + return check +} + +func probeCredential(cfg *cliConfig) doctorCheck { + check := doctorCheck{Name: "credential"} + if cfg.Token == "" { + check.Status = doctorFail + check.ExitCode = exitAuth + check.Detail = "no token configured" + check.Hint = notLoggedInHint + if !configFileExists() { + check.Hint += "; " + firstRunDeployHint + } + return check + } + // The value never leaves this function: only its origin is reported. + check.Status = doctorOK + check.Detail = "token present (from " + credentialSource() + ")" + return check +} + +// credentialSource names where the token came from without printing it. +func credentialSource() string { + if strings.TrimSpace(os.Getenv("MEM_TOKEN")) != "" { + return "$MEM_TOKEN" + } + return "config file" +} + +func probeWorkspace(ctx context.Context, cfg *cliConfig) doctorCheck { + check := doctorCheck{Name: "workspace"} + c := apiclient.New(cfg.Server, cfg.Token).WithWorkspace(cfg.Workspace) + var resp struct { + Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + } `json:"workspace"` + } + if err := c.DoJSON(ctx, http.MethodGet, "/v1/capabilities", nil, &resp); err != nil { + check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err) + return check + } + if resp.Workspace.ID == "" { + check.Status = doctorFail + check.ExitCode = exitNotFound + check.Detail = "server resolved no workspace for this credential" + check.Hint = "select one with `mem auth login` or --workspace " + return check + } + check.Status = doctorOK + if cfg.Workspace == "" { + check.Detail = fmt.Sprintf( + "server-resolved workspace %s (%s), role %s; none configured locally, using the server default", + resp.Workspace.Name, resp.Workspace.ID, resp.Workspace.Role, + ) + return check + } + check.Detail = fmt.Sprintf("workspace %s (%s), role %s", resp.Workspace.Name, resp.Workspace.ID, resp.Workspace.Role) + return check +} + +func probeVersion(ctx context.Context, server string, report *doctorReport) doctorCheck { + check := doctorCheck{Name: "version_skew"} + c := apiclient.New(server, "") + var resp struct { + Version string `json:"version"` + } + if err := c.DoJSON(ctx, http.MethodGet, "/v1/version", nil, &resp); err != nil { + check.Status, check.ExitCode, check.Detail, check.Hint = classifyProbe(err) + return check + } + report.ServerVersion = resp.Version + switch { + case resp.Version == "": + check.Status = doctorWarn + check.Detail = "server reported no version" + case cliVersion == "" || cliVersion == devCLIVersion: + // Honest limit, not a pass: release builds do not inject a CLI version + // yet, so there is nothing to compare against. + check.Status = doctorWarn + check.Detail = fmt.Sprintf( + "skew not computable: this CLI build reports %q (no version injected at build time); server reports %s", + cliVersion, resp.Version, + ) + check.Hint = "compare `mem version` against the release notes for the images you deployed" + case resp.Version == cliVersion: + check.Status = doctorOK + check.Detail = "CLI and server both report " + cliVersion + default: + check.Status = doctorWarn + check.Detail = fmt.Sprintf("CLI reports %s, server reports %s", cliVersion, resp.Version) + check.Hint = "upgrade the CLI or redeploy the server images so the two agree" + } + return check +} + +// classifyProbe turns a probe failure into the finding fields. The classification +// is shared with no other surface on purpose: ingest has a failure-code +// vocabulary for cycles, while this one maps to SPEC §7.1 process exit codes. +func classifyProbe(err error) (status string, code int, detail, hint string) { + var ae *apiclient.APIError + if errors.As(err, &ae) { + switch ae.Kind() { + case apiclient.KindAuth: + return doctorFail, exitAuth, fmt.Sprintf("server rejected the request (HTTP %d)", ae.StatusCode), notLoggedInHint + case apiclient.KindNotFound: + return doctorFail, exitNotFound, fmt.Sprintf("no mem server at this URL (HTTP %d)", ae.StatusCode), deployPathHint() + case apiclient.KindPlan, apiclient.KindQuota: + return doctorFail, exitPlanQuota, fmt.Sprintf("server refused for plan or quota (HTTP %d)", ae.StatusCode), "" + } + return doctorFail, exitProvider, fmt.Sprintf("server error (HTTP %d): %s", ae.StatusCode, ae.Message), deployPathHint() + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return doctorFail, exitProvider, "probe timed out", "raise --timeout, or check that the server is not behind a stalled proxy" + } + return doctorFail, exitProvider, "cannot reach the configured server: " + sanitizeProbeError(err), deployPathHint() +} + +// deployPathHint points at the container path the docs recommend, instead of a +// host-specific dependency recipe. +func deployPathHint() string { + return "start the documented container path: deploy/compose, see docs/DEPLOYMENT.md" +} + +// redactURL gates a configured URL on its way into a report an operator +// will paste into an issue. The policy lives in internal/redact so the CLI, the +// API client and memd share one implementation: a URL that can be positively +// proven to be a credential-free transport URL is echoed with its userinfo +// replaced, and one that cannot is withheld whole rather than scrubbed. +func redactURL(raw string) string { + return redact.URL(raw, redact.APIURLs) +} + +// sanitizeProbeError renders a probe failure without letting the configured URL +// out, including the shapes url.Parse reports as neither an error nor userinfo. +func sanitizeProbeError(err error) string { + return redact.TransportError(err, redact.APIURLs) +} + +func printDoctorReport(cmd *cobra.Command, r doctorReport) { + out := cmd.OutOrStdout() + fmt.Fprintf(out, "mem doctor (%s v%d)\n", r.Contract, r.SchemaVersion) + fmt.Fprintf(out, "server: %s\n", r.Server) + for _, c := range r.Checks { + fmt.Fprintf(out, "%-20s %-8s %s\n", c.Name, c.Status, c.Detail) + if c.Hint != "" { + fmt.Fprintf(out, "%-20s hint: %s\n", "", c.Hint) + } + } +} diff --git a/server/cmd/mem/cmds_doctor_test.go b/server/cmd/mem/cmds_doctor_test.go new file mode 100644 index 0000000..b9743db --- /dev/null +++ b/server/cmd/mem/cmds_doctor_test.go @@ -0,0 +1,748 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "github.com/PeterGuy326/mem/server/internal/redact" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// doctorStub answers the three endpoints mem doctor probes, and records every +// request. An unexpected method or path fails the test, which is how AC-002 +// ("performs no write request of any kind") is enforced. +type doctorStub struct { + mu sync.Mutex + requests []string + healthz func(http.ResponseWriter) + caps func(http.ResponseWriter) + version func(http.ResponseWriter) +} + +func newDoctorStub() *doctorStub { + s := &doctorStub{} + s.healthz = func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"ok":true}`)) + } + s.caps = func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"workspace":{"id":"11111111-1111-1111-1111-111111111111","name":"Personal","role":"owner"}}`)) + } + s.version = func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"version":"0.1.0"}`)) + } + return s +} + +func (s *doctorStub) server(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + if len(raw) > 0 { + t.Errorf("%s %s carried a request body: %s", r.Method, r.URL.Path, raw) + } + s.mu.Lock() + s.requests = append(s.requests, r.Method+" "+r.URL.Path) + s.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/healthz": + s.healthz(w) + case r.Method == http.MethodGet && r.URL.Path == "/v1/capabilities": + s.caps(w) + case r.Method == http.MethodGet && r.URL.Path == "/v1/version": + s.version(w) + default: + t.Errorf("doctor made an unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotImplemented) + } + })) +} + +func (s *doctorStub) seen() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.requests...) +} + +// configureDoctor points the CLI at server with the given credential state. +// writeConfig controls whether a config file exists on disk at all, which is the +// first-run distinction REQ-002 turns on. +func configureDoctor(t *testing.T, server, token string, writeConfig bool) { + t.Helper() + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if writeConfig { + if err := os.WriteFile(cfgPath, []byte("server: "+server+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + t.Setenv("MEM_CONFIG", cfgPath) + t.Setenv("MEM_SERVER", server) + t.Setenv("MEM_WORKSPACE", "") + t.Setenv("MEM_TOKEN", token) +} + +// execDoctor runs `mem doctor` with args and returns what it printed on stdout, +// what it printed on stderr (cobra's own error and usage text), and the error. +// main.go merges the two, but the report and cobra's noise are different surfaces +// and the assertions need to tell them apart. +func execDoctor(t *testing.T, args ...string) (string, string, error) { + t.Helper() + var stdout, stderr bytes.Buffer + root := newRootCmd() + root.SetOut(&stdout) + root.SetErr(&stderr) + root.SetArgs(append([]string{"doctor"}, args...)) + err := root.Execute() + return stdout.String(), stderr.String(), err +} + +func decodeReport(t *testing.T, out string) doctorReport { + t.Helper() + // A failing command's output buffer also carries cobra's own "Error:" and + // usage block: cobra writes them via OutOrStderr, which is this same writer + // when a test routes output into a buffer. In production the report is on + // stdout and cobra's noise is on stderr. Decoding the first JSON value keeps + // the assertion about the report itself. + dec := json.NewDecoder(strings.NewReader(strings.TrimSpace(out))) + var rep doctorReport + if err := dec.Decode(&rep); err != nil { + t.Fatalf("decode doctor json: %v\n%s", err, out) + } + return rep +} + +func checkByName(t *testing.T, rep doctorReport, name string) doctorCheck { + t.Helper() + for _, c := range rep.Checks { + if c.Name == name { + return c + } + } + t.Fatalf("no check named %q in %+v", name, rep.Checks) + return doctorCheck{} +} + +func cliCode(t *testing.T, err error) int { + t.Helper() + var ce *cliError + if !errors.As(err, &ce) { + t.Fatalf("error = %#v, want *cliError", err) + } + return ce.code +} + +func TestDoctorHealthyReportsAllChecksAndExitsZero(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "secret-token-value", true) + t.Cleanup(func() { cliVersion = devCLIVersion }) + cliVersion = "0.1.0" + + out, _, err := execDoctor(t, "--format", "json") + if err != nil { + t.Fatalf("healthy doctor returned %v\n%s", err, out) + } + rep := decodeReport(t, out) + if rep.ExitCode != exitOK { + t.Errorf("report exit_code = %d, want 0", rep.ExitCode) + } + want := []string{"server_reachability", "credential", "workspace", "version_skew"} + if len(rep.Checks) != len(want) { + t.Fatalf("checks = %d, want %d: %+v", len(rep.Checks), len(want), rep.Checks) + } + for i, name := range want { + if rep.Checks[i].Name != name { + t.Errorf("check %d = %q, want %q", i, rep.Checks[i].Name, name) + } + if rep.Checks[i].Status != doctorOK { + t.Errorf("%s status = %q (%s), want ok", name, rep.Checks[i].Status, rep.Checks[i].Detail) + } + } + // AC-002: exactly the three read probes, in order. + if got, wantReq := stub.seen(), []string{"GET /healthz", "GET /v1/capabilities", "GET /v1/version"}; strings.Join(got, ",") != strings.Join(wantReq, ",") { + t.Errorf("requests = %v, want %v", got, wantReq) + } + if strings.Contains(out, "secret-token-value") { + t.Errorf("report leaked the token value:\n%s", out) + } +} + +func TestDoctorUnreachableServer(t *testing.T) { + closed := httptest.NewServer(nil) + addr := closed.URL + closed.Close() + configureDoctor(t, addr, "tok", true) + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("doctor should exit non-zero for an unreachable server\n%s", out) + } + if code := cliCode(t, err); code != exitProvider { + t.Fatalf("exit code = %d, want %d", code, exitProvider) + } + rep := decodeReport(t, out) + c := checkByName(t, rep, "server_reachability") + if c.Status != doctorFail || c.ExitCode != exitProvider { + t.Errorf("reachability = %s/%d, want fail/%d", c.Status, c.ExitCode, exitProvider) + } + // The hint must name the documented container path, not a host recipe. + if !strings.Contains(c.Hint, "deploy/compose") || !strings.Contains(c.Hint, "docs/DEPLOYMENT.md") { + t.Errorf("hint = %q, want the documented deployment path", c.Hint) + } + for _, name := range []string{"workspace", "version_skew"} { + got := checkByName(t, rep, name) + if got.Status != doctorSkipped { + t.Errorf("%s = %s, want skipped", name, got.Status) + } + if !strings.Contains(got.Detail, "server_reachability") { + t.Errorf("%s detail = %q, want it to name the blocking check", name, got.Detail) + } + } +} + +func TestDoctorMissingCredential(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "", false) // no config file: first run + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("doctor should exit non-zero without a credential\n%s", out) + } + if code := cliCode(t, err); code != exitAuth { + t.Fatalf("exit code = %d, want %d", code, exitAuth) + } + rep := decodeReport(t, out) + c := checkByName(t, rep, "credential") + if c.Status != doctorFail || c.ExitCode != exitAuth { + t.Errorf("credential = %s/%d, want fail/%d", c.Status, c.ExitCode, exitAuth) + } + if !strings.Contains(c.Hint, "mem auth login") { + t.Errorf("hint = %q, want it to name `mem auth login`", c.Hint) + } + if !strings.Contains(c.Hint, "deploy/compose") { + t.Errorf("hint = %q, want first-run guidance naming the documented path", c.Hint) + } + if got, wantReq := stub.seen(), "GET /healthz"; strings.Join(got, ",") != wantReq { + t.Errorf("requests = %v, want only the health probe", got) + } +} + +// A machine that already has a config is not a first run: it must not be told to +// deploy a stack it is evidently already talking to. +func TestDoctorMissingCredentialOnConfiguredHost(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "", true) + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("want non-zero exit\n%s", out) + } + c := checkByName(t, decodeReport(t, out), "credential") + if !strings.Contains(c.Hint, "mem auth login") { + t.Errorf("hint = %q, want the login step", c.Hint) + } + if strings.Contains(c.Hint, "deploy/compose") { + t.Errorf("hint = %q, must not suggest deploying on an already-configured host", c.Hint) + } +} + +func TestDoctorNoWorkspaceSelected(t *testing.T) { + stub := newDoctorStub() + stub.caps = func(w http.ResponseWriter) { + _, _ = w.Write([]byte(`{"workspace":{"id":"","name":"","role":""}}`)) + } + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "tok", true) + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("doctor should exit non-zero when no workspace resolves\n%s", out) + } + if code := cliCode(t, err); code != exitNotFound { + t.Fatalf("exit code = %d, want %d", code, exitNotFound) + } + c := checkByName(t, decodeReport(t, out), "workspace") + if c.Status != doctorFail || c.ExitCode != exitNotFound { + t.Errorf("workspace = %s/%d, want fail/%d", c.Status, c.ExitCode, exitNotFound) + } +} + +func TestDoctorRejectedCredential(t *testing.T) { + stub := newDoctorStub() + stub.caps = func(w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"missing_bearer","code":"unauthorized"}`)) + } + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "expired-token", true) + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("doctor should exit non-zero on a rejected token\n%s", out) + } + if code := cliCode(t, err); code != exitAuth { + t.Fatalf("exit code = %d, want %d", code, exitAuth) + } + c := checkByName(t, decodeReport(t, out), "workspace") + if c.Status != doctorFail || c.ExitCode != exitAuth { + t.Errorf("workspace = %s/%d, want fail/%d", c.Status, c.ExitCode, exitAuth) + } +} + +// TestDoctorQuotaIsItsOwnCode pins the 4 (plan/quota) arm of the SPEC §7.1 map. +func TestDoctorQuotaIsItsOwnCode(t *testing.T) { + stub := newDoctorStub() + stub.caps = func(w http.ResponseWriter) { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"error":"quota_exceeded","code":"quota"}`)) + } + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "tok", true) + + out, _, err := execDoctor(t, "--format", "json") + if err == nil { + t.Fatalf("want non-zero exit\n%s", out) + } + if code := cliCode(t, err); code != exitPlanQuota { + t.Fatalf("exit code = %d, want %d\n%s", code, exitPlanQuota, out) + } +} + +func TestDoctorVersionSkew(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "tok", true) + t.Cleanup(func() { cliVersion = devCLIVersion }) + + // A dev build does not know its own version, so the check must say the + // comparison is impossible instead of claiming agreement. + cliVersion = devCLIVersion + out, _, err := execDoctor(t, "--format", "json") + if err != nil { + t.Fatalf("an advisory skew must not fail the run: %v\n%s", err, out) + } + c := checkByName(t, decodeReport(t, out), "version_skew") + if c.Status != doctorWarn || !strings.Contains(c.Detail, "not computable") { + t.Errorf("dev-build skew = %s (%s), want warn / not computable", c.Status, c.Detail) + } + + cliVersion = "0.0.9" + out, _, err = execDoctor(t, "--format", "json") + if err != nil { + t.Fatalf("skew should stay advisory: %v\n%s", err, out) + } + rep := decodeReport(t, out) + c = checkByName(t, rep, "version_skew") + if c.Status != doctorWarn || !strings.Contains(c.Detail, "0.0.9") || !strings.Contains(c.Detail, "0.1.0") { + t.Errorf("skew = %s (%s), want both versions named", c.Status, c.Detail) + } + if c.ExitCode != exitOK { + t.Errorf("skew exit contribution = %d, want 0 (advisory)", c.ExitCode) + } + if rep.ServerVersion != "0.1.0" { + t.Errorf("server_version = %q, want 0.1.0", rep.ServerVersion) + } + + cliVersion = "0.1.0" + out, _, _ = execDoctor(t, "--format", "json") + if c = checkByName(t, decodeReport(t, out), "version_skew"); c.Status != doctorOK { + t.Errorf("matching skew = %s (%s), want ok", c.Status, c.Detail) + } +} + +func TestDoctorNeverPrintsSecretValues(t *testing.T) { + const token = "sup3r-s3cret-token" + stub := newDoctorStub() + stub.caps = func(w http.ResponseWriter) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"workspace_forbidden","code":"forbidden"}`)) + } + srv := stub.server(t) + defer srv.Close() + + dir := t.TempDir() + cfg := filepath.Join(dir, "config.yaml") + body := fmt.Sprintf("server: %s\nemail: ops@corp\ntoken: %s\nworkspace: w-1\n", srv.URL, token) + if err := os.WriteFile(cfg, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("MEM_CONFIG", cfg) + t.Setenv("MEM_SERVER", srv.URL) + t.Setenv("MEM_TOKEN", token) + t.Setenv("MEM_WORKSPACE", "") + + for _, format := range []string{"text", "json"} { + stdout, stderr, _ := execDoctor(t, "--format", format) + if stdout == "" { + t.Fatalf("%s run produced no output", format) + } + for label, out := range map[string]string{"stdout": stdout, "stderr": stderr} { + if strings.Contains(out, token) { + t.Errorf("%s %s leaks the token value:\n%s", format, label, out) + } + } + } +} + +func TestRedactURLStripsUserinfo(t *testing.T) { + const secret = "dsn-p4ssw0rd" + got := redactURL("http://admin:" + secret + "@mem.internal:8787") + if strings.Contains(got, secret) { + t.Errorf("redactURL = %q, still carries the password", got) + } + if !strings.Contains(got, "REDACTED@mem.internal:8787") { + t.Errorf("redactURL = %q, want the userinfo replaced and the host kept", got) + } + got = redactURL("http://admin:" + secret + "@ho st.example.com:8787") + if strings.Contains(got, secret) { + t.Errorf("redactURL = %q, still carries the malformed-password value", got) + } + if got != redact.Placeholder && !strings.Contains(got, "REDACTED@ho st.example.com:8787") { + t.Errorf("redactURL = %q, want userinfo replaced, or the whole value withheld "+ + "once it can no longer be proven a transport URL", got) + } + plain := "http://localhost:8787" + if redactURL(plain) != plain { + t.Errorf("redactURL(%q) = %q, want it unchanged", plain, redactURL(plain)) + } +} + +func TestDoctorMalformedServerURLDoesNotLeakCredentials(t *testing.T) { + const secret = "malformed-psswd" + malformed := "http://admin:" + secret + "@ho st.example.com:1/healthz" + configureDoctor(t, malformed, "tok", true) + + for _, format := range []string{"json", "text"} { + stdout, stderr, err := execDoctor(t, "--format", format) + if err == nil { + t.Fatalf("doctor should fail with malformed server URL in %s format\n%s", format, stdout) + } + if strings.Contains(stdout, secret) { + t.Errorf("stdout leaked credential for %s: %s", format, stdout) + } + if strings.Contains(stderr, secret) { + t.Errorf("stderr leaked credential for %s: %s", format, stderr) + } + if strings.Contains(stdout, malformed) { + t.Errorf("stdout should not carry raw malformed URL in %s: %s", format, stdout) + } + if format == "json" { + rep := decodeReport(t, stdout) + reach := checkByName(t, rep, "server_reachability") + if strings.Contains(reach.Detail, secret) { + t.Errorf("server_reachability detail leaked secret: %s", reach.Detail) + } + if strings.Contains(reach.Detail, malformed) { + t.Errorf("server_reachability detail leaked raw URL: %s", reach.Detail) + } + if !strings.Contains(reach.Detail, redact.UserMarker) && + !strings.Contains(reach.Detail, redact.Placeholder) { + t.Errorf("server_reachability detail should redact or withhold credentials: %s", reach.Detail) + } + } + } +} + +// TestDoctorSchemelessServerURLDoesNotLeakCredentials covers the shape the +// previous scrubber could not see: url.Parse succeeds on it and reports no +// userinfo, because "admin" is read as the scheme and the credential lands in +// Opaque. A gate that keys on User != nil echoes it verbatim. +func TestDoctorSchemelessServerURLDoesNotLeakCredentials(t *testing.T) { + const secret = "schemeless-psswd" + schemeless := "admin:" + secret + "@mem.invalid:8787" + configureDoctor(t, schemeless, "tok", true) + + for _, format := range []string{"json", "text"} { + stdout, stderr, err := execDoctor(t, "--format", format) + if err == nil { + t.Fatalf("doctor should fail against an unreachable schemeless URL in %s format\n%s", format, stdout) + } + if strings.Contains(stdout, secret) || strings.Contains(stderr, secret) { + t.Errorf("%s output leaked the credential: stdout=%s stderr=%s", format, stdout, stderr) + } + if strings.Contains(stdout, schemeless) || strings.Contains(stderr, schemeless) { + t.Errorf("%s output echoed the raw schemeless URL: stdout=%s stderr=%s", format, stdout, stderr) + } + if format == "json" { + rep := decodeReport(t, stdout) + if strings.Contains(rep.Server, secret) { + t.Errorf("report server field leaked the credential: %s", rep.Server) + } + reach := checkByName(t, rep, "server_reachability") + if strings.Contains(reach.Detail, secret) || strings.Contains(reach.Hint, secret) { + t.Errorf("detail/hint leaked the credential: detail=%s hint=%s", reach.Detail, reach.Hint) + } + } + } +} + +func TestDoctorTextOutputIsAFixedOrderedList(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "tok", true) + t.Cleanup(func() { cliVersion = devCLIVersion }) + + out, _, err := execDoctor(t) + if err != nil { + t.Fatalf("healthy doctor returned %v\n%s", err, out) + } + names := []string{"server_reachability", "credential", "workspace", "version_skew"} + prev := -1 + for _, n := range names { + at := strings.Index(out, n) + if at < 0 { + t.Fatalf("text output missing %q:\n%s", n, out) + } + if at < prev { + t.Errorf("check %q printed out of order:\n%s", n, out) + } + prev = at + } + if !strings.Contains(out, "mem doctor (mem.doctor v1)") { + t.Errorf("text output missing the contract header:\n%s", out) + } + if strings.Contains(out, `"checks"`) { + t.Errorf("text output contains JSON:\n%s", out) + } +} + +func TestDoctorRejectsBadFlags(t *testing.T) { + dir := t.TempDir() + t.Setenv("MEM_CONFIG", filepath.Join(dir, "missing.yaml")) + t.Setenv("MEM_TOKEN", "tok") + + if _, _, err := execDoctor(t, "--format", "yaml"); err == nil { + t.Error("--format yaml should be rejected") + } + if _, _, err := execDoctor(t, "--timeout", "0s"); err == nil { + t.Error("--timeout 0s should be rejected") + } +} + +// doctorSchema mirrors the parts of docs/schemas/mem-doctor.v1.schema.json that +// this test can enforce without a draft-2020-12 evaluator: required keys, the +// closed enums, key admission (additionalProperties:false) and the fixed check +// order. +type doctorSchema struct { + Required []string `json:"required"` + Properties map[string]doctorSchemaNode `json:"properties"` + Defs map[string]doctorSchemaNode `json:"$defs"` +} + +type doctorSchemaNode struct { + Type string `json:"type"` + Enum []json.RawMessage `json:"enum"` + Const json.RawMessage `json:"const"` + Required []string `json:"required"` + Properties map[string]doctorSchemaNode `json:"properties"` + PrefixItems []json.RawMessage `json:"prefixItems"` +} + +func loadDoctorSchema(t *testing.T) doctorSchema { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "schemas", "mem-doctor.v1.schema.json")) + if err != nil { + t.Fatal(err) + } + var s doctorSchema + if err := json.Unmarshal(b, &s); err != nil { + t.Fatalf("checked-in schema is not parseable: %v", err) + } + if len(s.Required) == 0 || len(s.Properties) == 0 { + t.Fatalf("schema did not declare required keys or properties: %s", b) + } + if s.Defs["check"].Type != "object" { + t.Fatalf("schema missing $defs.check object: %s", b) + } + return s +} + +// TestDoctorJSONMatchesCheckedInSchema is AC-003. +func TestDoctorJSONMatchesCheckedInSchema(t *testing.T) { + stub := newDoctorStub() + srv := stub.server(t) + defer srv.Close() + configureDoctor(t, srv.URL, "tok", true) + t.Cleanup(func() { cliVersion = devCLIVersion }) + cliVersion = "0.1.0" + + out, _, err := execDoctor(t, "--format", "json") + if err != nil { + t.Fatalf("healthy doctor returned %v\n%s", err, out) + } + schema := loadDoctorSchema(t) + validateDoctorDoc(t, schema, []byte(out)) + + // httptest assigns an ephemeral port, which the report echoes in two places + // (server and the reachability detail). The golden pins everything except + // that, so drift in shape, order, wording or codes still fails loudly. + normalized := strings.ReplaceAll(out, srv.URL, "http://127.0.0.1:PORT") + + golden := filepath.Join("testdata", "doctor_healthy.golden.json") + if os.Getenv("MEM_UPDATE_GOLDEN") != "" { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(golden, []byte(normalized), 0o600); err != nil { + t.Fatal(err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("read golden: %v (create it with MEM_UPDATE_GOLDEN=1 go test ./cmd/mem/ -run TestDoctorJSON)", err) + } + if strings.TrimSpace(string(want)) != strings.TrimSpace(normalized) { + t.Errorf("doctor json drifted from %s\n--- want ---\n%s\n--- got ---\n%s", golden, want, normalized) + } +} + +func validateDoctorDoc(t *testing.T, s doctorSchema, doc []byte) { + t.Helper() + var obj map[string]json.RawMessage + if err := json.Unmarshal(doc, &obj); err != nil { + t.Fatalf("report is not a JSON object: %v", err) + } + for _, req := range s.Required { + if _, ok := obj[req]; !ok { + t.Errorf("report missing required key %q", req) + } + } + for key := range obj { + if _, ok := s.Properties[key]; !ok { + t.Errorf("report has key %q, which the schema forbids (additionalProperties:false)", key) + } + } + for _, key := range []string{"contract", "schema_version"} { + if !nodeAllows(s.Properties[key], obj[key]) { + t.Errorf("%s = %s, outside the schema's const", key, obj[key]) + } + } + if !nodeAllows(s.Properties["exit_code"], obj["exit_code"]) { + t.Errorf("exit_code = %s, outside the SPEC 7.1 set", obj["exit_code"]) + } + + var checks []map[string]json.RawMessage + if err := json.Unmarshal(obj["checks"], &checks); err != nil { + t.Fatalf("checks is not an array: %v", err) + } + if len(checks) != len(s.Properties["checks"].PrefixItems) { + t.Fatalf("checks length = %d, want %d", len(checks), len(s.Properties["checks"].PrefixItems)) + } + def := s.Defs["check"] + for i, c := range checks { + for _, req := range def.Required { + if _, ok := c[req]; !ok { + t.Errorf("checks[%d] missing required key %q", i, req) + } + } + for key := range c { + if _, ok := def.Properties[key]; !ok { + t.Errorf("checks[%d] has key %q the schema forbids", i, key) + } + } + for _, field := range []string{"name", "status", "exit_code"} { + if !nodeAllows(def.Properties[field], c[field]) { + t.Errorf("checks[%d].%s = %s, outside the schema's closed enum", i, field, c[field]) + } + } + // prefixItems pins the order, so a reordered report fails here. + var slot struct { + AllOf []struct { + Properties map[string]doctorSchemaNode `json:"properties"` + } `json:"allOf"` + } + if err := json.Unmarshal(s.Properties["checks"].PrefixItems[i], &slot); err != nil { + t.Fatalf("prefixItems[%d] unreadable: %v", i, err) + } + for _, sub := range slot.AllOf { + if want, ok := sub.Properties["name"]; ok && !nodeAllows(want, c["name"]) { + t.Errorf("checks[%d].name = %s, want %s (order is part of the contract)", i, c["name"], want.Enum) + } + } + } +} + +// nodeAllows reports whether raw satisfies a leaf schema doctorSchemaNode that constrains by +// const or enum. A leaf with neither declares no value constraint. +func nodeAllows(n doctorSchemaNode, raw json.RawMessage) bool { + value := strings.TrimSpace(string(raw)) + if len(n.Const) > 0 { + return strings.TrimSpace(string(n.Const)) == value + } + if len(n.Enum) > 0 { + for _, e := range n.Enum { + if strings.TrimSpace(string(e)) == value { + return true + } + } + return false + } + return true +} + +// TestNotLoggedInGuidance is REQ-002 on an existing command surface: the hint +// that used to stop at `mem auth login` must additionally name the documented +// deployment path, but only when no credential exists at all. +func TestNotLoggedInGuidance(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "missing.yaml") + existing := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(existing, []byte("server: http://127.0.0.1:1\n"), 0o600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + cfgPath string + want []string + deny string + }{ + {name: "first run", cfgPath: missing, want: []string{"mem auth login", "deploy/compose", "docs/DEPLOYMENT.md"}}, + {name: "configured but logged out", cfgPath: existing, want: []string{"mem auth login"}, deny: "deploy/compose"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("MEM_CONFIG", tc.cfgPath) + t.Setenv("MEM_TOKEN", "") + t.Setenv("MEM_SERVER", "") + var out bytes.Buffer + root := newRootCmd() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"search", "fy27 recruiting"}) + err := root.Execute() + var code int + if code = cliCode(t, err); code != exitAuth { + t.Fatalf("exit code = %d, want %d", code, exitAuth) + } + ce := err.(*cliError) + for _, want := range tc.want { + if !strings.Contains(ce.hint, want) { + t.Errorf("hint = %q, want it to name %q", ce.hint, want) + } + } + if tc.deny != "" && strings.Contains(ce.hint, tc.deny) { + t.Errorf("hint = %q, must not suggest deploying where a config exists", ce.hint) + } + }) + } +} diff --git a/server/cmd/mem/cmds_face.go b/server/cmd/mem/cmds_face.go index 4bed56c..fcb53ee 100644 --- a/server/cmd/mem/cmds_face.go +++ b/server/cmd/mem/cmds_face.go @@ -41,7 +41,7 @@ func newFaceListCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp faceListResp diff --git a/server/cmd/mem/cmds_file.go b/server/cmd/mem/cmds_file.go index 37f30a1..f2ed097 100644 --- a/server/cmd/mem/cmds_file.go +++ b/server/cmd/mem/cmds_file.go @@ -42,7 +42,7 @@ func newPutCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) sourceMetadata, err := cliSourceMetadata( @@ -491,7 +491,7 @@ func newVersionCmd() *cobra.Command { Use: "version", Short: "Print client + server version", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println("mem CLI dev") + fmt.Printf("mem CLI %s\n", cliVersion) cfg, _ := resolveConfig("") if cfg != nil && cfg.Server != "" { c := newHTTPClient(cfg) @@ -499,7 +499,7 @@ func newVersionCmd() *cobra.Command { Version string `json:"version"` } if err := c.doJSON(http.MethodGet, "/v1/version", nil, &resp); err == nil { - fmt.Printf("server: %s (%s)\n", resp.Version, cfg.Server) + fmt.Printf("server: %s (%s)\n", resp.Version, redactURL(cfg.Server)) } } return nil diff --git a/server/cmd/mem/cmds_file_annotations.go b/server/cmd/mem/cmds_file_annotations.go index c27c3ea..d22e646 100644 --- a/server/cmd/mem/cmds_file_annotations.go +++ b/server/cmd/mem/cmds_file_annotations.go @@ -82,7 +82,7 @@ func configuredFileAnnotationClient() (*apiclient.Client, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg).api, nil } diff --git a/server/cmd/mem/cmds_folder.go b/server/cmd/mem/cmds_folder.go index 08d2517..8bc138d 100644 --- a/server/cmd/mem/cmds_folder.go +++ b/server/cmd/mem/cmds_folder.go @@ -22,7 +22,7 @@ func newMkdirCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp map[string]any diff --git a/server/cmd/mem/cmds_handoff.go b/server/cmd/mem/cmds_handoff.go index dc6133f..b094244 100644 --- a/server/cmd/mem/cmds_handoff.go +++ b/server/cmd/mem/cmds_handoff.go @@ -58,7 +58,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } raw, err := newHTTPClient(cfg).api.Checkpoint( commandContext(cmd), @@ -265,7 +265,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } raw, err := newHTTPClient(cfg).api.Resume( commandContext(cmd), diff --git a/server/cmd/mem/cmds_ingest.go b/server/cmd/mem/cmds_ingest.go index 2ed5b20..bb41a15 100644 --- a/server/cmd/mem/cmds_ingest.go +++ b/server/cmd/mem/cmds_ingest.go @@ -158,7 +158,7 @@ func runIngestQoder(cmd *cobra.Command, o ingestOptions) error { return err } if !o.dryRun && cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } client := newHTTPClient(cfg) stateDir := o.checkpointDir() diff --git a/server/cmd/mem/cmds_memory.go b/server/cmd/mem/cmds_memory.go index b842ef5..1c43a93 100644 --- a/server/cmd/mem/cmds_memory.go +++ b/server/cmd/mem/cmds_memory.go @@ -123,7 +123,7 @@ cursor and bounded memory summaries for scripts.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } options := apiclient.MemoryListOptions{ @@ -393,7 +393,7 @@ func configuredMemoryClient() (*apiclient.Client, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg).api, nil } diff --git a/server/cmd/mem/cmds_model.go b/server/cmd/mem/cmds_model.go index 9ff8237..84c1997 100644 --- a/server/cmd/mem/cmds_model.go +++ b/server/cmd/mem/cmds_model.go @@ -425,7 +425,7 @@ func activateLocalModelProfile( return providerSetResp{}, err } if cfg.Token == "" { - return providerSetResp{}, newCliError(3, "not logged in", "run `mem auth login` first") + return providerSetResp{}, errNotLoggedIn() } var response providerSetResp client := newHTTPClient(cfg) diff --git a/server/cmd/mem/cmds_profile.go b/server/cmd/mem/cmds_profile.go index a65ce6e..9092e14 100644 --- a/server/cmd/mem/cmds_profile.go +++ b/server/cmd/mem/cmds_profile.go @@ -143,7 +143,7 @@ func configuredWorkspaceAIProfileClient() (*httpClient, error) { return nil, err } if cfg.Token == "" { - return nil, newCliError(3, "not logged in", "run `mem auth login` first") + return nil, errNotLoggedIn() } return newHTTPClient(cfg), nil } diff --git a/server/cmd/mem/cmds_provider.go b/server/cmd/mem/cmds_provider.go index 322dac3..d31e3e2 100644 --- a/server/cmd/mem/cmds_provider.go +++ b/server/cmd/mem/cmds_provider.go @@ -56,7 +56,7 @@ func newProviderListCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp providerListResp @@ -116,7 +116,7 @@ vectors cannot silently enter different spaces.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) kind := args[0] @@ -164,7 +164,7 @@ historical provider identity was not recorded.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var resp struct { Provider string `json:"provider"` @@ -201,7 +201,7 @@ func newProviderTestCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) kind := args[0] diff --git a/server/cmd/mem/cmds_related.go b/server/cmd/mem/cmds_related.go index 4146a31..818c28c 100644 --- a/server/cmd/mem/cmds_related.go +++ b/server/cmd/mem/cmds_related.go @@ -82,7 +82,7 @@ Relation types currently supported: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) path := "/v1/files/" + args[0] + "/related" @@ -161,7 +161,7 @@ outgoing rows before recomputing.`, return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) body := rebuildReq{FileID: file} diff --git a/server/cmd/mem/cmds_remember.go b/server/cmd/mem/cmds_remember.go index 345dadc..e736920 100644 --- a/server/cmd/mem/cmds_remember.go +++ b/server/cmd/mem/cmds_remember.go @@ -115,7 +115,7 @@ Examples: return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } var resp map[string]any diff --git a/server/cmd/mem/cmds_search.go b/server/cmd/mem/cmds_search.go index ac866df..47704ec 100644 --- a/server/cmd/mem/cmds_search.go +++ b/server/cmd/mem/cmds_search.go @@ -56,7 +56,7 @@ func newSearchCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) diff --git a/server/cmd/mem/cmds_timeline.go b/server/cmd/mem/cmds_timeline.go index 1edb1f3..eef049a 100644 --- a/server/cmd/mem/cmds_timeline.go +++ b/server/cmd/mem/cmds_timeline.go @@ -43,7 +43,7 @@ func newTimelineCmd() *cobra.Command { return err } if cfg.Token == "" { - return newCliError(3, "not logged in", "run `mem auth login` first") + return errNotLoggedIn() } c := newHTTPClient(cfg) var resp timelineResp diff --git a/server/cmd/mem/config.go b/server/cmd/mem/config.go index 38d75d3..7388795 100644 --- a/server/cmd/mem/config.go +++ b/server/cmd/mem/config.go @@ -91,3 +91,16 @@ func resolveConfig(serverOverride string) (*cliConfig, error) { } return c, nil } + +// configFileExists reports whether a CLI config file is present on disk. +// loadConfig deliberately succeeds without one, so this is the only signal that +// separates "never configured" from "configured, but not logged in" — the +// distinction first-run guidance has to get right. +func configFileExists() bool { + p, err := configPath() + if err != nil { + return false + } + _, err = os.Stat(p) + return err == nil +} diff --git a/server/cmd/mem/main.go b/server/cmd/mem/main.go index 2205c6d..e882e90 100644 --- a/server/cmd/mem/main.go +++ b/server/cmd/mem/main.go @@ -20,6 +20,17 @@ var ( cliWorkspaceOverride string ) +// devCLIVersion is the placeholder this build reports when no version was +// injected. +const devCLIVersion = "dev" + +// cliVersion is the CLI's reported version. Nothing injects it yet: +// .github/workflows/release.yml builds with `-s -w` only, so release binaries +// currently report devCLIVersion, and `mem doctor`'s version_skew check reports +// "skew not computable" instead of inventing a comparison. Wiring this up is a +// release-side change (GOFLAGS/-ldflags=-X main.cliVersion=…), not a CLI one. +var cliVersion = devCLIVersion + func main() { root := newRootCmd() ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) @@ -85,6 +96,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newModelCmd()) root.AddCommand(newTimelineCmd()) root.AddCommand(newWorkspaceCmd()) + root.AddCommand(newDoctorCmd()) root.AddCommand(newVersionCmd()) return root } diff --git a/server/cmd/mem/testdata/doctor_healthy.golden.json b/server/cmd/mem/testdata/doctor_healthy.golden.json new file mode 100644 index 0000000..7fcef97 --- /dev/null +++ b/server/cmd/mem/testdata/doctor_healthy.golden.json @@ -0,0 +1,34 @@ +{ + "contract": "mem.doctor", + "schema_version": 1, + "server": "http://127.0.0.1:PORT", + "cli_version": "0.1.0", + "server_version": "0.1.0", + "exit_code": 0, + "checks": [ + { + "name": "server_reachability", + "status": "ok", + "exit_code": 0, + "detail": "healthz ok at http://127.0.0.1:PORT" + }, + { + "name": "credential", + "status": "ok", + "exit_code": 0, + "detail": "token present (from $MEM_TOKEN)" + }, + { + "name": "workspace", + "status": "ok", + "exit_code": 0, + "detail": "server-resolved workspace Personal (11111111-1111-1111-1111-111111111111), role owner; none configured locally, using the server default" + }, + { + "name": "version_skew", + "status": "ok", + "exit_code": 0, + "detail": "CLI and server both report 0.1.0" + } + ] +} diff --git a/server/cmd/memd/main.go b/server/cmd/memd/main.go index 37cb47d..3e08444 100644 --- a/server/cmd/memd/main.go +++ b/server/cmd/memd/main.go @@ -8,7 +8,6 @@ import ( "fmt" "log/slog" "net/http" - "net/url" "os" "os/signal" "path/filepath" @@ -34,6 +33,7 @@ import ( "github.com/PeterGuy326/mem/server/internal/memory" "github.com/PeterGuy326/mem/server/internal/provider" "github.com/PeterGuy326/mem/server/internal/queue" + "github.com/PeterGuy326/mem/server/internal/redact" "github.com/PeterGuy326/mem/server/internal/relator" "github.com/PeterGuy326/mem/server/internal/search" "github.com/PeterGuy326/mem/server/internal/storage" @@ -45,7 +45,9 @@ import ( func main() { if err := run(); err != nil { - slog.Error("memd fatal", "err", err) + // run() wraps third-party errors that embed the configured DSN verbatim, + // and slog renders an error value as its text. + slog.Error("memd fatal", "err", redact.Text(err.Error(), redact.StoreURLs)) os.Exit(1) } } @@ -444,13 +446,9 @@ func redactDSN(s string) string { return redactURLCredentials(s) } +// redactURLCredentials gates the two DSNs this process logs. The store schemes +// are allowed here and nowhere else, so an API-shaped egress can never echo a +// database URL by accident. func redactURLCredentials(raw string) string { - parsed, err := url.Parse(raw) - if err != nil || parsed.User == nil { - return raw - } - if _, hasPassword := parsed.User.Password(); !hasPassword { - return raw - } - return parsed.Redacted() + return redact.URL(raw, redact.StoreURLs) } diff --git a/server/cmd/memd/main_test.go b/server/cmd/memd/main_test.go index f36a0fb..eeaff4a 100644 --- a/server/cmd/memd/main_test.go +++ b/server/cmd/memd/main_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/PeterGuy326/mem/server/internal/redact" "github.com/PeterGuy326/mem/server/internal/workspacebundle" ) @@ -15,14 +16,36 @@ func TestRedactURLCredentials(t *testing.T) { tests := []struct { name string raw string + want string }{ { name: "postgres", raw: "postgres://mem:database-secret@postgres:5432/mem?sslmode=disable", + want: "postgres://REDACTED@postgres:5432/mem?sslmode=REDACTED", }, { name: "redis", raw: "redis://:redis-secret@redis:6379/0", + want: "redis://REDACTED@redis:6379/0", + }, + { + // The old scrubber echoed this raw because it only masked values with a + // password; the gate treats any userinfo as a credential. + name: "username only", + raw: "postgres://mem@postgres:5432/mem?sslmode=disable", + want: "postgres://REDACTED@postgres:5432/mem?sslmode=REDACTED", + }, + { + // url.Parse reads the scheme as "redis" and parks the rest in Opaque, + // so a u.User check never fires. + name: "redis without a transport scheme", + raw: "redis:redis-secret@redis:6379/0", + want: redact.Placeholder, + }, + { + name: "postgres that fails to parse", + raw: "postgres://mem:database-secret@post gres:5432/mem", + want: redact.Placeholder, }, } for _, test := range tests { @@ -30,23 +53,22 @@ func TestRedactURLCredentials(t *testing.T) { t.Run(test.name, func(t *testing.T) { t.Parallel() got := redactURLCredentials(test.raw) + if got != test.want { + t.Fatalf("redactURLCredentials(%q) = %q, want %q", test.raw, got, test.want) + } if strings.Contains(got, "secret") { t.Fatalf("credentials leaked from %q: %q", test.raw, got) } - if !strings.Contains(got, "@") { - t.Fatalf("redacted URL lost its endpoint: %q", got) - } }) } } -func TestRedactURLCredentialsLeavesPasswordlessValuesAlone(t *testing.T) { +func TestRedactURLCredentialsLeavesCredentialFreeValuesAlone(t *testing.T) { t.Parallel() for _, raw := range []string{ "redis://redis:6379/0", - "redis:6379", - "://not-a-url", + "http://mem.internal:8787", } { if got := redactURLCredentials(raw); got != raw { t.Fatalf("redactURLCredentials(%q) = %q", raw, got) @@ -54,6 +76,23 @@ func TestRedactURLCredentialsLeavesPasswordlessValuesAlone(t *testing.T) { } } +// TestRedactURLCredentialsWithholdsWhatItCannotVerify pins the fail-closed half: +// these shapes carry no visible password today, but the gate cannot prove that +// from a parse it either failed or had to attribute to an unknown scheme, so +// logging them at all would be guessing. +func TestRedactURLCredentialsWithholdsWhatItCannotVerify(t *testing.T) { + t.Parallel() + + for _, raw := range []string{ + "redis:6379", + "://not-a-url", + } { + if got := redactURLCredentials(raw); got != redact.Placeholder { + t.Fatalf("redactURLCredentials(%q) = %q, want %q", raw, got, redact.Placeholder) + } + } +} + func TestWorkspaceTransferBundleLimitsAreConservativeAndConsistent(t *testing.T) { defaults := workspacebundle.DefaultLimits() limits := workspaceTransferBundleLimits() diff --git a/server/internal/apiclient/apiclient.go b/server/internal/apiclient/apiclient.go index 38dfabd..df01390 100644 --- a/server/internal/apiclient/apiclient.go +++ b/server/internal/apiclient/apiclient.go @@ -12,6 +12,8 @@ import ( "strconv" "strings" "time" + + "github.com/PeterGuy326/mem/server/internal/redact" ) const sourceMetadataHeader = "X-Mem-Source-Metadata" @@ -99,7 +101,7 @@ func (c *Client) DoJSONWithHeaders(ctx context.Context, method, path string, bod } req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rdr) if err != nil { - return err + return requestBuildError(method, c.baseURL+path, err) } if body != nil { req.Header.Set("Content-Type", "application/json") @@ -110,7 +112,7 @@ func (c *Client) DoJSONWithHeaders(ctx context.Context, method, path string, bod c.attachAuth(req) resp, err := c.hc.Do(req) if err != nil { - return err + return gateTransportError(err) } defer resp.Body.Close() return decode(resp, out) @@ -169,13 +171,13 @@ func (c *Client) UploadMultipartWithSourceMetadata(ctx context.Context, name, mi req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/files", pr) if err != nil { - return err + return requestBuildError(http.MethodPost, c.baseURL+"/v1/files", err) } req.Header.Set("Content-Type", mw.FormDataContentType()) c.attachAuth(req) resp, err := c.hc.Do(req) if err != nil { - return err + return gateTransportError(err) } defer resp.Body.Close() if werr := <-errCh; werr != nil { @@ -214,9 +216,10 @@ func (c *Client) UploadStreamWithSourceMetadata(ctx context.Context, name, mimeT if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/files?"+q.Encode(), body) + target := c.baseURL + "/v1/files?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, body) if err != nil { - return err + return requestBuildError(http.MethodPost, target, err) } if sourceJSON != "" { req.Header.Set(sourceMetadataHeader, sourceJSON) @@ -230,7 +233,7 @@ func (c *Client) UploadStreamWithSourceMetadata(ctx context.Context, name, mimeT c.attachAuth(req) resp, err := c.hc.Do(req) if err != nil { - return err + return gateTransportError(err) } defer resp.Body.Close() return decode(resp, out) @@ -239,14 +242,15 @@ func (c *Client) UploadStreamWithSourceMetadata(ctx context.Context, name, mimeT // DownloadStream returns a streaming reader for GET /v1/files/{id}/content. // Callers MUST close the returned ReadCloser. func (c *Client) DownloadStream(ctx context.Context, fileID string) (io.ReadCloser, string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/files/"+fileID+"/content", nil) + dlTarget := c.baseURL + "/v1/files/" + fileID + "/content" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, dlTarget, nil) if err != nil { - return nil, "", err + return nil, "", requestBuildError(http.MethodGet, dlTarget, err) } c.attachAuth(req) resp, err := c.hc.Do(req) if err != nil { - return nil, "", err + return nil, "", gateTransportError(err) } if resp.StatusCode >= 400 { defer resp.Body.Close() @@ -268,6 +272,51 @@ func marshalSourceMetadata(sourceMetadata *FileSourceMetadata) (string, error) { return string(raw), nil } +// requestBuildError and gateTransportError are the two halves of one rule: the +// configured base URL can carry credentials, and both http.NewRequestWithContext +// and http.Client.Do put that URL into the error they return. Go masks the +// password there but not the username, and masks nothing for a value it parses +// as an opaque scheme, so the URL goes through the shared gate instead. +// +// The wrappers keep the cause reachable through Unwrap so callers can still +// classify a timeout with errors.Is after the text has been rewritten. +func requestBuildError(method, target string, err error) error { + return &redactErr{ + cause: err, + message: fmt.Sprintf("%s %s: %s", method, redact.URL(target, redact.APIURLs), redact.Text(err.Error(), redact.APIURLs)), + } +} + +// newRequest is the construction site for the requests that are issued outside +// the DoJSON and Upload helpers, so a base URL that cannot be parsed fails +// through the same gate here as it does there. +func (c *Client) newRequest(ctx context.Context, method, target string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, target, body) + if err != nil { + return nil, requestBuildError(method, target, err) + } + return req, nil +} + +func gateTransportError(err error) error { + if err == nil { + return nil + } + return &redactErr{ + cause: err, + message: redact.TransportError(err, redact.APIURLs), + } +} + +type redactErr struct { + cause error + message string +} + +func (e *redactErr) Error() string { return e.message } + +func (e *redactErr) Unwrap() error { return e.cause } + func (c *Client) attachAuth(req *http.Request) { if c.token != "" { req.Header.Set("Authorization", "Bearer "+c.token) diff --git a/server/internal/apiclient/apiclient_test.go b/server/internal/apiclient/apiclient_test.go index 0d61000..def3d8a 100644 --- a/server/internal/apiclient/apiclient_test.go +++ b/server/internal/apiclient/apiclient_test.go @@ -155,3 +155,63 @@ func TestUploadStreamWithSourceMetadata(t *testing.T) { t.Fatal("source_metadata leaked into the request URL") } } + +func TestRequestBuildErrorRedactsCredentialedURL(t *testing.T) { + const secret = "bad-token-xyz" + err := New("http://admin:"+secret+"@ho st.example.com:1", "token").DoJSON( + context.Background(), + http.MethodGet, + "/v1/test", + nil, + nil, + ) + if err == nil { + t.Fatal("expected request construction to fail for malformed URL") + } + if strings.Contains(err.Error(), secret) { + t.Fatalf("request-build error leaked credential: %v", err) + } + if !strings.Contains(err.Error(), "REDACTED") { + t.Fatalf("request-build error should redact credentials: %v", err) + } +} + +// A URL whose scheme is really a username parses, so request construction +// succeeds and the failure comes from the transport instead. Every entry point +// has its own http.Client.Do site, so each needs its own case: covering request +// construction does not cover request execution. +func TestTransportErrorRedactsCredentialedURL(t *testing.T) { + const secret = "bad-token-xyz" + schemeless := "admin:" + secret + "@mem.invalid:8787" + + cases := []struct { + name string + call func(*Client) error + }{ + {"DoJSON", func(c *Client) error { + return c.DoJSON(context.Background(), http.MethodGet, "/v1/test", nil, nil) + }}, + {"UploadMultipart", func(c *Client) error { + return c.UploadMultipart(context.Background(), "f.txt", "text/plain", "", strings.NewReader("x"), nil, nil) + }}, + {"UploadStream", func(c *Client) error { + return c.UploadStream(context.Background(), "f.txt", "text/plain", "", 1, nil, strings.NewReader("x"), nil) + }}, + {"DownloadStream", func(c *Client) error { + _, _, err := c.DownloadStream(context.Background(), "file-1") + return err + }}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.call(New(schemeless, "token")) + if err == nil { + t.Fatal("expected the transport to fail for a schemeless base URL") + } + if strings.Contains(err.Error(), secret) { + t.Errorf("%s transport error leaked credential: %v", tc.name, err) + } + }) + } +} diff --git a/server/internal/apiclient/workspace_transfer.go b/server/internal/apiclient/workspace_transfer.go index 6273a97..1c64dcc 100644 --- a/server/internal/apiclient/workspace_transfer.go +++ b/server/internal/apiclient/workspace_transfer.go @@ -70,7 +70,7 @@ type WorkspaceImportConflict struct { // in memory. The server does not publish response headers until its complete // archive has been built and validated. func (c *Client) ExportWorkspace(ctx context.Context) (*WorkspaceBundleDownload, error) { - req, err := http.NewRequestWithContext( + req, err := c.newRequest( ctx, http.MethodGet, c.baseURL+"/v1/workspaces/current/export", @@ -133,7 +133,7 @@ func (c *Client) ImportWorkspace( return nil, fmt.Errorf("workspace bundle size must be -1 or non-negative") } query := url.Values{"mode": []string{mode}} - req, err := http.NewRequestWithContext( + req, err := c.newRequest( ctx, http.MethodPost, c.baseURL+"/v1/workspaces/current/import?"+query.Encode(), diff --git a/server/internal/redact/redact.go b/server/internal/redact/redact.go new file mode 100644 index 0000000..c324715 --- /dev/null +++ b/server/internal/redact/redact.go @@ -0,0 +1,182 @@ +// Package redact gates URLs on their way out of the process. +// +// The gate is fail-closed on purpose. A configured URL can carry a password in +// shapes that url.Parse does not report as userinfo: "admin:pw@host" parses as +// Scheme="admin", Opaque="pw@host", User=nil. So "the parser found no userinfo" +// is not evidence that a value is credential-free, and any implementation that +// gates on u.User == nil echoes the credential unchanged. A value the gate +// cannot positively prove safe is withheld whole. +// +// Scrubbing a message that already contains the URL is not a substitute: Go +// renders url.Error with %q, so a quote inside a password arrives escaped and a +// scanner that pairs quotes mis-pairs and replaces nothing. Callers hand text +// here instead and accept withholding when a piece cannot be verified. +package redact + +import ( + "errors" + "net/url" + "strings" +) + +// Placeholder replaces a value the gate cannot prove credential-free. It is a +// fixed token so an operator can tell withholding apart from a real host. +// Deliberately free of '<', '>' and '&': encoding/json escapes those, so an +// angle-bracketed marker would render differently in text and JSON output. +const Placeholder = "[withheld]" + +// UserMarker replaces the userinfo of a URL, and the value of every query +// parameter, when the rest of the URL is safe to echo. It uses only unreserved +// characters because url.User("***") would percent-encode the asterisks and +// url.Values.Encode() would do the same to a marked-up query value. +const UserMarker = "REDACTED" + +// APIURLs are the schemes a client base URL may legitimately use. +var APIURLs = []string{"http", "https"} + +// StoreURLs are the schemes memd logs: the database DSN and the queue DSN. +var StoreURLs = []string{"http", "https", "postgres", "postgresql", "redis", "rediss", "redis+unix", "unix"} + +// URL returns raw with userinfo replaced by UserMarker, or Placeholder when the +// value cannot be proven credential-free. +func URL(raw string, allowed []string) string { + safe, ok := rewrite(raw, allowed) + if !ok { + return Placeholder + } + return safe +} + +// Text renders diagnostic text — an error message, typically — so that no +// URL-shaped token inside it can carry userinfo out. A single unverifiable +// token withholds the entire message rather than trimming that token, because a +// delimiter inside a credential splits the text into pieces that no longer look +// like a URL, and the piece without the "@" is exactly the half that leaked. +// +// Query and fragment values are part of the same problem: pgx honours +// postgres://host/db?password=x as the real password, so a value that parses as +// a clean URL is not thereby proven credential-free. +func Text(msg string, allowed []string) string { + out := msg + for _, token := range urlTokens(msg) { + safe, ok := rewrite(token, allowed) + if !ok { + return Placeholder + } + if safe != token { + out = strings.Replace(out, token, safe, 1) + } + } + return out +} + +// TransportError renders an error returned by http.Client.Do. The URL travels +// through the gate rather than through Go's own masking, which strips the +// password but leaves the username, and the wrapped cause is kept so the +// message still names what failed. +func TransportError(err error, allowed []string) string { + if err == nil { + return "" + } + var ue *url.Error + if errors.As(err, &ue) && ue.Err != nil { + return Text(ue.Op, allowed) + " " + URL(ue.URL, allowed) + ": " + Text(ue.Err.Error(), allowed) + } + return Text(err.Error(), allowed) +} + +// rewrite reports whether raw is a URL we can prove carries no credential, and +// returns the form that is safe to echo. +func rewrite(raw string, allowed []string) (string, bool) { + if raw == "" { + return "", true + } + parsed, err := url.Parse(raw) + if err != nil { + return "", false + } + // Credentials hide in Opaque precisely when the scheme is really userinfo, + // and an empty or unknown scheme means we are not looking at a transport URL + // we can reason about. + if parsed.Opaque != "" || !allowedScheme(parsed.Scheme, allowed) { + return "", false + } + if parsed.User != nil { + parsed.User = url.User(UserMarker) + } + // A credential can travel as a connection parameter, and pgx honours + // postgres://host/db?password=… as the real password. Blanking only the keys + // that look secret would claim that we can prove some other value is not a + // credential, which is the claim this package refuses to make, so every query + // value goes and only the parameter names survive. A fragment has no name to + // keep, so it withholds the URL. + // + // ponytail: that costs ?sslmode=disable its value in memd's startup log. If + // it costs someone a debugging minute, keep an allowlist of parameters that + // cannot carry a secret and fail every unknown one to the marker. + if parsed.RawQuery != "" { + q, err := url.ParseQuery(parsed.RawQuery) + if err != nil { + return "", false + } + blanked := make(url.Values, len(q)) + for key := range q { + blanked[key] = []string{UserMarker} + } + parsed.RawQuery = blanked.Encode() + } + if parsed.Fragment != "" { + return "", false + } + // What leaves the process is the re-serialised form, so verify that instead + // of trusting the first parse: String() can rebuild something different from + // the input, and an "@" surviving into the host means userinfo was never in + // the field we stripped. + rendered := parsed.String() + back, err := url.Parse(rendered) + if err != nil || back.Opaque != "" || back.Host != parsed.Host || + !strings.EqualFold(back.Scheme, parsed.Scheme) || strings.Contains(back.Host, "@") { + return "", false + } + if back.User != nil { + if _, hasPassword := back.User.Password(); hasPassword { + return "", false + } + if back.User.Username() != UserMarker { + return "", false + } + } + return rendered, true +} + +func allowedScheme(scheme string, allowed []string) bool { + for _, want := range allowed { + if strings.EqualFold(scheme, want) { + return true + } + } + return false +} + +// urlTokens returns the whitespace- and quote-delimited runs of msg that look +// like they could carry a host or userinfo. Splitting on delimiters is safe +// because any run produced this way still holds either the "@" or the "://" +// that marked the original as credential-shaped, unless the original held +// neither and was never a URL at all. +func urlTokens(msg string) []string { + var tokens []string + for _, token := range strings.FieldsFunc(msg, isDelimiter) { + if strings.Contains(token, "@") || strings.Contains(token, "://") { + tokens = append(tokens, token) + } + } + return tokens +} + +func isDelimiter(r rune) bool { + switch r { + case ' ', '\t', '\n', '\r', '"', '\'', '`', '(', ')', '[', ']', '{', '}', '<', '>', ',': + return true + } + return false +} diff --git a/server/internal/redact/redact_test.go b/server/internal/redact/redact_test.go new file mode 100644 index 0000000..1f6392b --- /dev/null +++ b/server/internal/redact/redact_test.go @@ -0,0 +1,291 @@ +package redact + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "testing" +) + +// secret marks every fixture below. No case may let it reach the returned +// string, and the tests fail closed on the marker rather than on a specific +// redaction shape. +const secret = "s3ntinel-p4ssw0rd" + +func TestURLWithholdsShapesTheParserCannotAttribute(t *testing.T) { + cases := []struct { + name string + raw string + allowed []string + want string + }{ + { + name: "userinfo on a recognised scheme", + raw: "http://admin:" + secret + "@mem.internal:8787", + allowed: APIURLs, + want: "http://REDACTED@mem.internal:8787", + }, + { + name: "username only", + raw: "http://admin@mem.internal:8787", + allowed: APIURLs, + want: "http://REDACTED@mem.internal:8787", + }, + { + name: "no credential at all is echoed unchanged", + raw: "http://localhost:8787", + allowed: APIURLs, + want: "http://localhost:8787", + }, + { + name: "empty value has nothing to leak", + raw: "", + allowed: APIURLs, + want: "", + }, + { + name: "store scheme is allowed for a DSN egress", + raw: "postgres://mem:" + secret + "@localhost:5432/mem?sslmode=disable", + allowed: StoreURLs, + want: "postgres://REDACTED@localhost:5432/mem?sslmode=REDACTED", + }, + // The shape this package exists for: url.Parse succeeds, User is nil and + // the whole credential sits in Opaque, so a u.User != nil gate misses it. + { + name: "no scheme, credential in Opaque", + raw: "admin:" + secret + "@mem.internal:8787", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "scheme the egress does not use", + raw: "gopher://" + secret + "@mem.internal:8787", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "store scheme on an API egress", + raw: "postgres://mem:" + secret + "@localhost:5432/mem", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "parse failure with a space in the host", + raw: "http://admin:" + secret + "@ho st.example.com:8787", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "parse failure with a bad percent escape", + raw: "http://admin:" + secret + "@mem.internal:%zz", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "parse failure with a space in the password", + raw: "http://admin:" + secret + " x@mem.internal:8787", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "parse failure ending in an escape sign", + raw: "http://admin:" + secret + "@%", + allowed: APIURLs, + want: Placeholder, + }, + { + name: "out-of-range port still parses, so userinfo is stripped", + raw: "http://admin:" + secret + "@mem.internal:99999999", + allowed: APIURLs, + want: "http://REDACTED@mem.internal:99999999", + }, + { + name: "non-numeric port does not parse", + raw: "http://admin:" + secret + "@mem.internal:notaport", + allowed: APIURLs, + want: Placeholder, + }, + { + // No scheme is not a recognised transport scheme, so the adjudicated + // rule withholds it even though the credential did land in User. + name: "scheme-relative value has no scheme to check", + raw: "//admin:" + secret + "@mem.internal:8787", + allowed: APIURLs, + want: Placeholder, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := URL(tc.raw, tc.allowed) + if got != tc.want { + t.Errorf("URL(%q) = %q, want %q", tc.raw, got, tc.want) + } + if strings.Contains(got, secret) { + t.Errorf("URL(%q) = %q, leaks the sentinel", tc.raw, got) + } + }) + } +} + +func TestTextWithholdsWhenAnyCredentialShapedTokenIsUnverifiable(t *testing.T) { + cases := []struct { + name string + msg string + want string + }{ + { + name: "plain transport cause survives", + msg: `dial tcp: lookup mem.internal: no such host`, + want: `dial tcp: lookup mem.internal: no such host`, + }, + { + name: "well-formed credential URL is redacted in place", + msg: `Get "http://admin:` + secret + `@mem.internal:8787/healthz": dial tcp refused`, + want: `Get "http://REDACTED@mem.internal:8787/healthz": dial tcp refused`, + }, + { + name: "no-scheme shape withholds the whole line", + msg: `Get "admin:` + secret + `@mem.internal:8787": unsupported protocol scheme "admin"`, + want: Placeholder, + }, + { + name: "quote-escaped password withholds the whole line", + msg: `Get "http://admin:` + secret + `\"@mem.internal:8787": context deadline exceeded`, + want: Placeholder, + }, + { + name: "space-split password withholds the whole line", + msg: `Get "http://admin:` + secret + ` x@mem.internal:8787": dial tcp`, + want: Placeholder, + }, + { + name: "at sign in a path is not a credential", + msg: `GET https://mem.internal/v1/files/report%40mem.internal: permission denied`, + want: `GET https://mem.internal/v1/files/report%40mem.internal: permission denied`, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := Text(tc.msg, APIURLs) + if got != tc.want { + t.Errorf("Text(%q) = %q, want %q", tc.msg, got, tc.want) + } + if strings.Contains(got, secret) { + t.Errorf("Text(%q) = %q, leaks the sentinel", tc.msg, got) + } + }) + } +} + +// TestTextGatesAStoreDsnEmbeddedInAForeignError covers the shape that reaches +// memd's fatal log: asynq puts the whole DSN into its parse error and +// queue.NewClient wraps that verbatim. +func TestTextGatesAStoreDsnEmbeddedInAForeignError(t *testing.T) { + msg := `queue: parse redis url: asynq: could not parse redis uri: ` + + `parse "redis://:` + secret + `@ho st:6379/0": invalid character " " in host name` + + got := Text(msg, StoreURLs) + if strings.Contains(got, secret) { + t.Errorf("Text(%q) = %q, leaks the sentinel", msg, got) + } +} + +// TestQueryAndFragmentCredentialsAreWithheld pins the shape the userinfo gate +// used to miss: pgx honours postgres://host/db?password=… as a real password, so +// a URL that parses cleanly with User == nil is not thereby proven safe. Names +// of parameters survive so a log line still says which settings are on; no value +// does, and a bare secret in a fragment withholds the URL. +func TestQueryAndFragmentCredentialsAreWithheld(t *testing.T) { + cases := []struct { + raw string + want string + }{ + { + raw: "redis://queue.internal:6379/0?password=" + secret, + want: "redis://queue.internal:6379/0?password=REDACTED", + }, + { + raw: "postgres://mem@db.internal:5432/mem?sslmode=require&password=" + secret, + want: "postgres://REDACTED@db.internal:5432/mem?password=REDACTED&sslmode=REDACTED", + }, + { + raw: "redis://queue.internal:6379/0#" + secret, + want: Placeholder, + }, + } + + for _, tc := range cases { + if got := URL(tc.raw, StoreURLs); got != tc.want { + t.Errorf("URL(%q) = %q, want %q", tc.raw, got, tc.want) + } + if got := Text(tc.raw, StoreURLs); strings.Contains(got, secret) { + t.Errorf("Text(%q) = %q, leaks the sentinel", tc.raw, got) + } + } +} + +func TestTransportErrorNamesTheFailureWithoutEchoingCredentials(t *testing.T) { + sentinel := "http://admin:" + secret + "@mem.internal:8787" + cases := []struct { + name string + err error + }{ + { + name: "url error carrying parsed userinfo", + err: &url.Error{Op: "Get", URL: sentinel, Err: errors.New("dial tcp: connection refused")}, + }, + { + name: "url error whose URL is not a transport scheme", + err: &url.Error{Op: "Get", Err: errors.New("unsupported protocol scheme \"admin\""), URL: "admin:" + secret + "@mem.internal:8787"}, + }, + { + name: "bare error mentioning the URL in prose", + err: fmt.Errorf("proxy returned 407 for %s", sentinel), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := TransportError(tc.err, APIURLs) + if strings.Contains(got, secret) { + t.Errorf("TransportError(%v) = %q, leaks the sentinel", tc.err, got) + } + if got == "" { + t.Errorf("TransportError(%v) = %q, want the failure to stay diagnosable", tc.err, got) + } + }) + } + + if got := TransportError(nil, APIURLs); got != "" { + t.Errorf("TransportError(nil) = %q, want empty", got) + } +} + +// TestTransportErrorKeepsErrorIdentity pins that rendering does not replace the +// chain a caller classifies on: doctor maps a timeout to exit 5 via errors.Is on +// context.DeadlineExceeded, and that has to keep working after the text changes. +func TestTransportErrorKeepsErrorIdentity(t *testing.T) { + sentinel := "http://admin:" + secret + "@mem.internal:8787" + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, &url.Error{Op: "Get", URL: sentinel, Err: context.DeadlineExceeded} + })} + _, err := client.Get(sentinel) + if err == nil { + t.Fatal("expected a transport failure") + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("transport error lost its cause: %v", err) + } + if rendered := TransportError(err, APIURLs); strings.Contains(rendered, secret) { + t.Errorf("TransportError = %q, leaks the sentinel", rendered) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } From 55d09b203ba9dc011f140d13d6bbb32f186fc16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Fri, 11 Sep 2026 00:23:43 +0800 Subject: [PATCH 12/31] build(worker): raise the pypdf floor and pin to 6.18.0 (#188) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Not a review vote, and not an acceptance I am the author of this branch, so under this org's `require_code_owner` + `require_last_push_approval` configuration my own ticket cannot be the one that clears it. Nothing here asks for a merge, a close, a label, or a tag. The commitment is only: here is a change, here is what I measured. Supersedes nothing by itself: **#156 is a separate, still-valid Dependabot PR.** See *Sequencing* below. ### What changes Two files, +39/−39: - `worker/pyproject.toml:31` — `"pypdf>=4.0"` → `"pypdf>=6.18.0"` - `worker/uv.lock` — `pypdf 6.15.0 → 6.18.0`, nothing else moves That is the whole diff. No test, no docs, no CI file, no `CHANGELOG.md` entry: `grep -i 'pypdf\|numpy\|torch\|dependabot' CHANGELOG.md` on `main` returns **zero** dependency-bump entries across its 381 lines, and #156 is 2 files too, so an entry here would be inventing a convention. ### Why the floor is written `>=6.18.0` and not `>=6.16.1` Three open Dependabot alerts name `pypdf` in this lock — `#71` (`GHSA-jp53-mhqp-8xcg`, `< 6.16.0`), `#72` (`GHSA-763m-79hh-57f2`, `< 6.16.1`), `#73` (`GHSA-23w6-3w8w-8484`, `< 6.16.1`). This satisfies all three, the same as #156, once it is on the default branch. It additionally takes two upstream releases that **have no advisory**, so no alert will ever schedule them: - **6.17.0** (2026-09-04) — `Security (SEC): Limit value for Roman numerals (#4047)` - **6.18.0** (2026-09-07) — `Security (SEC): Limit allowed length of indirect object tokens (#4055)` Re-measured for this PR: `GET /advisories?affects=pypdf` → **43** entries, all `type: reviewed`, and the newest `first_patched_version` anywhere in that set is **6.16.1**. Both new entries are the same hardening class as the three above (bound an unbounded parser input), and the surface is the untrusted one: `worker/mem_worker/processors/pdf.py:79` runs `PdfReader(BytesIO(file.data))` on **user-uploaded** files. Writing the floor at 6.18.0 rather than letting the lock alone carry it is the part that survives a from-scratch resolve. Measured: **uv resolves to the newest release satisfying a floor, not to the floor** — `>=6.16.1` re-resolved today lands on 6.18.0, and after the next release it would land past it, in a manifest whose stated requirement was never 6.18.0. ### Evidence All of it executed on a tarball of `main @ 2986fe38175f54d99f15dd38a498708c6ecd88cd` whose tree was proved equal to the remote (`.commit.tree.sha` `f447ca554377e85ba26090d59882b8d4f2b78731` == `git init && git add -A && git write-tree`), with the tool CI pins — uv `0.9.27` (`ci.yml:185-187`) — in a sanitized env (no mirror/proxy variables; `uv lock` through a mirror-configured uv rewrites thousands of URL lines and its "lock is stale" verdict is then worthless). | # | check | result | | --- | --- | --- | | 1 | `uv lock` on the **untouched** tree | **0 diff lines** vs committed `worker/uv.lock` — the pin is reproducible, so everything below is about the resolve, not a stale tool | | 2 | `uv lock` after only the constraint edit | `Updated pypdf v6.15.0 -> v6.18.0`, 76 lock lines | | 3 | name/version pairs, both locks | 85 packages each, **exactly one** difference: `pypdf 6.15.0 → 6.18.0` | | 4 | `uv lock --locked` | `rc=0` — so the Worker leg's `uv sync --frozen` (`ci.yml:193`, `UV_FROZEN: "1"` at `:170`) accepts it | | 5 | dist hashes vs the registry | sdist `ae58b7d93c22c169ffb02c3b06321c45c4f223b4916536568adb57d789d95d01`, wheel `05b762b77bcb9dcb4a7c91fcf5dded585b25bee7269ab3d3001d7c55fa1b324b` — byte-identical to `pypi.org/pypi/pypdf/json` | | 6 | the repo's own fixture (`test_processor_logic.py:419`) extracted at 6.15.0 / 6.16.1 / 6.18.0 on **Python 3.11** (`ci.yml:182`) | identical `page_count`, identical `sha256(extracted_text)` (`46e7c072b2684841…`, 214 chars), `"1800 RMB" in text` true on all three, malformed input raises the same `PdfStreamError` on all three | | 7 | 6.18.0's only behavioural change (`DEP: Rework configuration value handling (#4044)`) | `grep` over the whole tree for `overwrite_configuration` / `apply_configuration` / `disable_legacy_handling` / `pypdf.constants` → **0 hits**; `pypdf` appears in only 3 files (`worker/pyproject.toml`, `worker/mem_worker/processors/pdf.py`, `scripts/seed_demo_data.sh`) | Checks 1–4 and 7 are the ones CI cannot shortcut; 6 is the one that says "the worker's PDF path behaves the same", and 4 is the one that says "this lock is self-consistent". The Worker test leg on the exact head remains the real proof of the full pipeline (`test_processor_logic.py:457`) — **that runs in CI, I did not run it here**; the local venv has none of the Worker's own dependencies installed. About the diff size: of the 76 changed lock lines, **8 are pypdf** (specifier, version, sdist, wheel — each on both sides). The other 68 are uv's marker renormalisation: the `python_full_version >= '3.15' and sys_platform == 'darwin'` fork marker hopping between `torch 2.13.0` and `2.13.0+cpu` (likewise `torchvision 0.28.0` / `+cpu`), `python_full_version` narrowing dropping off `numpy` / `scipy` / `tifffile`, and the `resolution-markers` list reordering. That is not avoidable churn I chose: **a plain `uv lock` on either side of a pin costs 0 lines, but any resolve that moves pypdf pays them** — #156's own `+53/−53` is the same shape. No other package's *version* changes. ### Process: this PR is ahead of its issue's readiness gate `AGENTS.md` rule 2 says not to implement a material change until its issue has acceptance criteria and `status:ready`. Refs #187, which I filed with the AC list and reproduction steps for exactly this; it is `status:needs-triage`, and `status:ready` is a maintainer's label, not mine to set. So this is a **draft**, and undrafting it is the step that should follow that label rather than precede it. If triage would rather the change go in behind #156, #187 can simply wait. ### Sequencing with #156 Both branches touch the same two files, so they will conflict with each other, not with `main`. - If **#156 merges first**: rebase this onto the new `main` — the manifest line is a one-token edit and the lock is one `uv lock` with the pinned tool. I offered to do that; it is not something to do unasked. - If **this merges first**: #156's diff is a strict subset of this one and its three alerts are dismissed by this branch instead. **I am not asking for #156 to be closed** — it has an in-force `APPROVED` from `PeterGuy326` on its current head and I am not in a position to spend someone else's ticket. That call, and the release-cadence call about whether 6.17.0/6.18.0 belong in this repo's Worker at all, is the owner's. ### What I did not do No review submitted, no vote, no merge, no close, no label change on anything except the new issue's own labels, no ref moved on #156, no `Update branch` clicked anywhere, no `CHANGELOG.md` edit, no tag, no publish. Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com> --- worker/pyproject.toml | 2 +- worker/uv.lock | 76 +++++++++++++++++++++---------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/worker/pyproject.toml b/worker/pyproject.toml index 0481b7f..9fead71 100644 --- a/worker/pyproject.toml +++ b/worker/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # Imaging "pillow>=10.2", # PDF (placeholder — W2 actually uses) - "pypdf>=4.0", + "pypdf>=6.18.0", # HTTP "requests>=2.31", "httpx>=0.27", diff --git a/worker/uv.lock b/worker/uv.lock index cd0c2b8..6ded14e 100644 --- a/worker/uv.lock +++ b/worker/uv.lock @@ -948,10 +948,10 @@ asr = [ ] clip = [ { name = "open-clip-torch" }, - { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, ] dev = [ { name = "mypy" }, @@ -986,7 +986,7 @@ requires-dist = [ { name = "protobuf", specifier = ">=7.35.1" }, { name = "pydantic", specifier = ">=2.6" }, { name = "pydantic-settings", specifier = ">=2.2" }, - { name = "pypdf", specifier = ">=4.0" }, + { name = "pypdf", specifier = ">=6.18.0" }, { name = "pytest", marker = "extra == 'test'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23" }, { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0" }, @@ -1344,10 +1344,10 @@ dependencies = [ { name = "regex" }, { name = "safetensors" }, { name = "timm" }, - { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, { name = "tqdm" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4a/1f/2bc9795047fa2c1ad2567ef78ce6dfc9a7b763fa534acee09a94da2a5b8f/open_clip_torch-3.3.0.tar.gz", hash = "sha256:904b1a9f909df8281bb3de60ab95491cd2994a509177ea4f9d6292a84fe24d6d", size = 1503380, upload-time = "2026-02-27T00:32:46.74Z" } @@ -1644,11 +1644,11 @@ wheels = [ [[package]] name = "pypdf" -version = "6.15.0" +version = "6.18.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/17/ee75a92718ec7212de831e71454d702225aa5e474a805cce169806044453/pypdf-6.15.0.tar.gz", hash = "sha256:d39c4d955a76409284a905e2d65b40076d77ab76129e0faaeeb6612403ecfc79", size = 6993794, upload-time = "2026-08-06T13:06:49.929Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/c3/9fa0666f280552bd3833562d985b785fce1ddb3804937edd2fd6a3f2bdb3/pypdf-6.18.0.tar.gz", hash = "sha256:ae58b7d93c22c169ffb02c3b06321c45c4f223b4916536568adb57d789d95d01", size = 7024871, upload-time = "2026-09-07T16:48:16.444Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/72/ce3067ac31e214a66388159f8462ddb8c13dd00170f24d555a1f1ae8ee91/pypdf-6.15.0-py3-none-any.whl", hash = "sha256:14e001d6504822cb1ca9c7ed9a69bccb320f59b320730f55af804361abe4d5ee", size = 378123, upload-time = "2026-08-06T13:06:47.709Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a5/d5922a078c9a612327681d4793404f82998f4d66213add6fdc0659245856/pypdf-6.18.0-py3-none-any.whl", hash = "sha256:05b762b77bcb9dcb4a7c91fcf5dded585b25bee7269ab3d3001d7c55fa1b324b", size = 393848, upload-time = "2026-09-07T16:48:14.254Z" }, ] [[package]] @@ -2248,10 +2248,10 @@ dependencies = [ { name = "huggingface-hub" }, { name = "pyyaml" }, { name = "safetensors" }, - { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, - { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, - { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "torchvision", version = "0.28.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torchvision", version = "0.28.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/35/03/e41389ac641747bfec48d016fde8be1eade1901e6f2c1aedcb0c8cb4b5d9/timm-1.0.28.tar.gz", hash = "sha256:3789d313fdd5541a327b60180d70dbb4bdec73db8ff0655e413db3c3d134a9a4", size = 2451413, upload-time = "2026-07-11T17:24:32.615Z" } wheels = [ @@ -2344,19 +2344,18 @@ name = "torch" version = "2.13.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform == 'darwin'" }, - { name = "fsspec", marker = "sys_platform == 'darwin'" }, - { name = "jinja2", marker = "sys_platform == 'darwin'" }, - { name = "networkx", marker = "sys_platform == 'darwin'" }, - { name = "setuptools", marker = "sys_platform == 'darwin'" }, - { name = "sympy", marker = "sys_platform == 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, + { name = "filelock", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "fsspec", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "jinja2", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "networkx", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "setuptools", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "sympy", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e76f9bcecc52b8ff711239a2f7547d5353df95878ab232f0773c1d95928b92f8", upload-time = "2026-07-08T12:26:13Z" }, @@ -2375,15 +2374,16 @@ resolution-markers = [ "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", + "python_full_version >= '3.15' and sys_platform == 'darwin'", ] dependencies = [ - { name = "filelock", marker = "sys_platform != 'darwin'" }, - { name = "fsspec", marker = "sys_platform != 'darwin'" }, - { name = "jinja2", marker = "sys_platform != 'darwin'" }, - { name = "networkx", marker = "sys_platform != 'darwin'" }, - { name = "setuptools", marker = "sys_platform != 'darwin'" }, - { name = "sympy", marker = "sys_platform != 'darwin'" }, - { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, + { name = "filelock", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "fsspec", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "jinja2", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "networkx", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "setuptools", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "sympy", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.13.0%2Bcpu-cp311-cp311-linux_s390x.whl", hash = "sha256:6e9817dbdf5ea76789babd46e457eac5bf14ff566cf85f8addbfdff2d56601ce", upload-time = "2026-07-08T19:27:52Z" }, @@ -2420,16 +2420,15 @@ name = "torchvision" version = "0.28.0" source = { registry = "https://download.pytorch.org/whl/cpu" } resolution-markers = [ - "python_full_version >= '3.15' and sys_platform == 'darwin'", "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform == 'darwin'", "python_full_version == '3.12.*' and sys_platform == 'darwin'", "python_full_version < '3.12' and sys_platform == 'darwin'", ] dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform == 'darwin'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform == 'darwin'" }, - { name = "pillow", marker = "sys_platform == 'darwin'" }, - { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "pillow", marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, + { name = "torch", version = "2.13.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:83fe6c020866a85acd7d97deccc45ff11d66daf42916d04396a4309c66c0ccb8", upload-time = "2026-07-08T12:26:40Z" }, @@ -2448,12 +2447,13 @@ resolution-markers = [ "python_full_version >= '3.13' and python_full_version < '3.15' and sys_platform != 'darwin'", "python_full_version == '3.12.*' and sys_platform != 'darwin'", "python_full_version < '3.12' and sys_platform != 'darwin'", + "python_full_version >= '3.15' and sys_platform == 'darwin'", ] dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12' and sys_platform != 'darwin'" }, - { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and sys_platform != 'darwin'" }, - { name = "pillow", marker = "sys_platform != 'darwin'" }, - { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.12' and sys_platform != 'darwin') or (python_full_version >= '3.15' and sys_platform == 'darwin')" }, + { name = "pillow", marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "torch", version = "2.13.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, ] wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.28.0%2Bcpu-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:22958193d72444ed7cbcc665ba4821a31e5279f9c4d1ad08520918b30896b78a", upload-time = "2026-07-08T12:26:39Z" }, From 3e8acaeb1b8c5a46a00a63beacc3efc9299474d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:15:17 +0000 Subject: [PATCH 13/31] build(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.2 in /server (#158) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.2.
Release notes

Sourced from google.golang.org/grpc's releases.

Release 1.83.2

Security

  • server: Reject requests missing both :authority and Host headers with HTTP 400 and status Internal. (grpc/grpc-go#9365)

Release 1.83.1

Security

  • xds/rbac: Fix a bug where nested Principal or Permission rules with :scheme or grpc- prefixed header matchers were not rejected, which could cause DENY rules to fail open. (#9258)
  • xds/rbac: Fix a bug where the host header matcher was not being replaced with :authority in nested Principal or Permission rules. (#9258)
  • xds/rbac: Fix a bug where a header matcher whose name was not lowercase, such as X-Role, matched no header, which could cause DENY rules to fail open. (#9332)
  • xds/rbac: Fix a bug where a :scheme or grpc- prefixed header matcher was accepted when its name was not lowercase. (#9332)
  • xds/rbac: Fix a bug where a Host header matcher was not replaced with :authority. (#9332)

Performance

  • transport: Restrict memory overhead of buffering small data frames. (#9331)

Release 1.83.0

Security

  • server: Stop reading from connections when flooded by HTTP/2 frames to mitigate resource exhaustion. The default value for this limit is 100 frames, excluding DATA and HEADERS, and may be changed by setting environment variable GRPC_GO_EXPERIMENTAL_CONTROL_BUFFER_THROTTLE_LIMIT.
  • xds/rbac: Support Metadata and RequestedServerName permissions matcher fields. If present in a DENY rule, previously these would be ignored and fail-open.
  • xds/rbac: Fix panic when parsing unsupported fields in NotRule/NotId permissions.
  • xds/rbac: Support the deprecated source_ip principal identifier by treating it as equivalent to direct_remote_ip.
  • xds: Fix panic when parsing route header matchers configured with empty exact_match, prefix_match, or suffix_match strings. (#9223)

New Features

  • xds/googlec2p: Enable DirectPath over Interconnect support for on-premises clients via the force-xds target URI query parameter. (#9133)
  • xds: Enable xDS configuration to control which fields get propagated from ORCA backend metric reports to LRS load reports. (#9145)
  • authz: Add OnPolicyUpdate callback to FileWatcherOptions to notify when an authz policy is loaded or updated. (#9142)
  • xds: Add support for the GCP Authentication HTTP Filter, which automatically fetches and attaches GCP Service Account Identity JWT tokens to outgoing RPCs.
    • This feature can be enabled by setting environment variable GRPC_EXPERIMENTAL_XDS_GCP_AUTHENTICATION_FILTER=true. (#9119)
  • xds: Add support for xDS-based HTTP CONNECT proxies.
    • This feature can be enabled by setting environment variable GRPC_EXPERIMENTAL_XDS_HTTP_CONNECT=true. (#9151)
  • xds: Add support for contains_match in route header matchers. (#9223)

Bug Fixes

  • credentials/alts: Fix panic when processing malformed frames by validating that the message frame length exceeds the message type field size. (#9197)
  • grpc: Fix compilation on Plan 9 targets (GOOS=plan9), broken since v1.81.0. (#9255)

... (truncated)

Commits

Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com> --- server/go.mod | 10 +++++----- server/go.sum | 40 ++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/server/go.mod b/server/go.mod index a81d46a..f9cd20f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -10,10 +10,10 @@ require ( github.com/minio/minio-go/v7 v7.0.77 github.com/pressly/goose/v3 v3.22.1 github.com/spf13/cobra v1.8.1 - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.2 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -40,9 +40,9 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.9 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect ) diff --git a/server/go.sum b/server/go.sum index 345d1a6..b86b253 100644 --- a/server/go.sum +++ b/server/go.sum @@ -94,40 +94,40 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 3c13f04e6417911d045a3b86149a4d9b657d7091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:49:25 +0800 Subject: [PATCH 14/31] docs(governance): add GOVERNANCE.md with the tag-ruleset record corrected (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this does Adds `GOVERNANCE.md`, the release governance charter for this repository: branch protection on `main`, `refs/tags/v*` immutability, the stricter bar for release-cut pull requests, the CODEOWNERS policy, and an incident runbook. It is additive. It does not change any workflow, setting, or rule, and it explicitly subordinates itself to live repository configuration. ## Why this is a new pull request rather than #125 #125 carries the same charter, but its head branch lives in a fork (`waterbro-8/mem`). `.github/workflows/bytefolk-security.yml:36` guards the `codeql` job with: ```yaml if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} ``` `codeql` needs `security-events: write` to upload SARIF, which GitHub does not grant to a `pull_request` run from a fork, so skipping it there is correct. The problem is what skipping produces. Because `codeql` is a matrix job skipped by a job-level `if:`, GitHub never expands the matrix and publishes the check run under the literal, un-interpolated name `CodeQL (${{ matrix.language }})` with conclusion `SKIPPED`. That name matches none of `CodeQL (go)`, `CodeQL (javascript-typescript)`, or `CodeQL (python)`, which `main` requires. Required contexts that are never reported are treated as pending forever. So #125 cannot merge at any number of approvals. `Update branch`, auto-merge, and re-running workflows all fail to change that. Re-pointing the work onto a branch inside `bytefolk/mem` is the only resolution that needs no policy change and grants no new permission, which is why this pull request exists. #125 should be closed as superseded by this one. ## What differs from #125's content The charter is otherwise the same document, but four claims in its Tag immutability and Incident runbook sections did not match the live configuration. They are corrected here. **1. "two active rulesets, neither of which has bypass actors" — false.** | Ruleset | Blocks | Bypass actors | | --- | --- | --- | | `21888356` Protect stable release tags | `update`, `deletion` | none | | `21899500` Restrict stable release tag creation | `creation` | repository role `admin` (`repositoryRoleDatabaseId` 5), `bypass_mode: always` | Read from `GET /repos/bytefolk/mem/rulesets/{id}`, and confirmed via GraphQL `RepositoryRulesetBypassActor.repositoryRoleName`, which returns the string `admin` directly rather than an id that has to be interpreted. The asymmetry is deliberate and is now stated as such: creation stays reachable so a release can always be cut, while published tags are immutable for every role including admin. Immutability is enforced by `21888356`, not by `21899500`. **2. "GitHub exposes no creation timestamp for rulesets" — false.** The individual ruleset endpoint returns `created_at` and `updated_at`, so the pair is orderable: `21888356` at `2026-08-31T00:34:47Z`, `21899500` at `2026-08-31T04:47:11Z` (updated 54 seconds later at `04:48:05Z`). The charter previously declined to say which rule came first on the strength of that incorrect premise. **3. "If no such path exists when a release is needed, that is a blocker" — a path exists.** A repository admin can cut a `v*` tag directly. `v0.1.1` demonstrates it: annotated tag object `c2ecc1c49ff8bbe13b9d7800bc910e4b7ac99b74` dereferences to commit `cc727db0bc72655f299166de1f60756f5c686cc7` and is tagged `2026-08-31T06:32:10Z`, one hour and forty-five minutes after `21899500` became active, by a repository admin. **4. Incident runbook step 4 repeated claim 1.** This is the one with operational consequences. As drafted, a maintainer working a compromised-release incident at 3am would have read that tag creation "has no bypass actors" and that a missing creation path "is escalated, not worked around" — and would have escalated a tag cut that a repository admin can simply perform. The step now says it is an admin action that requires no ruleset change, and that no ruleset change should be made in order to perform it. **Probable cause of the error, now recorded in the document.** The collection endpoint `GET /repos/{owner}/{repo}/rulesets` renders `bypass_actors` as `null` for every ruleset. A reader who trusts that rendering concludes no bypass actors exist anywhere. The corrected section names the per-ruleset endpoint explicitly so the mistake is harder to repeat. ## What was verified and left alone All nine branch-protection values the charter asserts were re-read from `GET /repos/bytefolk/mem/branches/main/protection` and are correct as written, so that section is unchanged: `enforce_admins: true` · `required_approving_review_count: 1` · `require_code_owner_reviews: true` · `dismiss_stale_reviews: true` · `require_last_push_approval: true` · `required_linear_history: true` · `required_conversation_resolution: true` · `allow_force_pushes: false` · `allow_deletions: false` · `bypass_pull_request_allowances: null` One sentence was added to that section to keep the two controls distinct: branch protection having no bypass actors is a separate fact from tag creation having an admin bypass, and conflating them is what makes claim 1 plausible on a first read. The required-check enumeration in that section is unchanged and remains explicitly hedged as "evidence of what runs, not a substitute for the configuration". It is dated 2026-09-03 and does not list `CodeQL (go)`, `CodeQL (javascript-typescript)`, `CodeQL (python)`, or `Dependency review`, all of which are in fact required on `main` today. The hedge already covers this, but a reviewer who wants the list refreshed should say so rather than let it stand as a near-miss. ## Attribution The charter's first commit (`d2600211`, aligning it with the enforced protections) and the `main`-sync merges are PeterGuy326's. The commit tracking the live CODEOWNERS policy and both tag rulesets (`470babed`, 2026-09-03) is waterbro-8's and is credited via `Co-authored-by`. The four corrections above are mine and are not attributed to waterbro-8. ## Checks - Docs-only. Adds one file, `GOVERNANCE.md`. No code, workflow, configuration, or dependency change, so no runtime behaviour is affected. - Title is Conventional and the body links `#124` and `#125` for `pr-policy`. - Head branch is inside `bytefolk/mem`, so `codeql` is not skipped and the three required CodeQL contexts will be reported normally. Refs #124 Refs #125 Co-authored-by: waterbro-8 <318569545+waterbro-8@users.noreply.github.com> --- GOVERNANCE.md | 162 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 GOVERNANCE.md diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..8c53b3b --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,162 @@ +# Release Governance + +This document supplements the repository workflow in [AGENTS.md](AGENTS.md) and the +release policy in [docs/maintainers/releasing.md](docs/maintainers/releasing.md). It +records the repository's branch-protection, tag-immutability, and release-cut review +governance without weakening or replacing either document. Repository settings are +the immediate mechanical enforcement; any drift between them and this additive +charter must be corrected without weakening the stronger rule. + +## Motivation + +The v0.1.0 release on 2026-08-30 exposed three gaps: + +1. The `v0.1.0` tag had multiple create/delete cycles in one day while a broken + release workflow was iterated on. Tag history was not immutable. +2. Issue #81 resolved the structurally unsatisfiable single-CODEOWNER setup by + adding a second owner. A separate gap remained: branch protection did not apply + to administrators, so an administrator could merge without the otherwise-required + independent approval. The recent-five audit covered #117, #115, #114, #108, and + #105. +3. A broken `download-artifact` SHA reached the release workflow because no reviewer + saw the release-cut PR before it was merged. + +Issue #124 records the resulting decisions through revision R4: R2 applied the narrow +admin-enforcement change, R3 canonicalized the founder-approved addition of a third code +owner, and R4 corrected the lifecycle record. The issue is closed. The matching branch +protection and the two `refs/tags/v*` rulesets enforce it mechanically. + +Where this charter states a configuration value, it records a point-in-time read of that +configuration. GitHub's live settings stay authoritative and some of them are readable +only by repository administrators, so a stale line here is a documentation defect to +correct, never a change in enforcement and never a reason to weaken the stronger rule. + +## Branch protection on `main` + +`main` is protected with the following non-negotiable settings: + +- **`enforce_admins: true`** — administrators are **not** exempt, preventing future + administrator bypasses without changing the status of historical merges. This value is + the field-for-field read-back recorded in #124 after the narrow + `POST .../protection/enforce_admins` change. Contributors without admin cannot read the + endpoint; they verify it by observing that a merge is blocked, not by reading it. +- **Strict required status checks** (`strict: true`): every required check must pass on a + head that is up to date with `main` before a merge. The authoritative required-check list + is repository configuration readable only by administrators. As of 2026-09-03 the check + jobs observed on this repository are `Go`, `Worker`, `Web`, + `Conventional title and linked issue`, `Workflow, scripts and Compose`, + `PostgreSQL integration`, `Web memory and transfer acceptance`, + `HTTP, CLI and MCP lifecycle`, `Agent host MCP contract`, `Deployment profiles`, + `Offline recall benchmark`, `npm wrapper`, and `npm wrapper compatibility` + (`node18-linux`, `node20-linux`, `node24-windows`). That enumeration is evidence of what + runs, not a substitute for the configuration, and it is not presented here as the exact + required set. +- **Required pull request reviews**: `required_approving_review_count = 1`, + `require_code_owner_reviews = true`, `dismiss_stale_reviews = true`, and + `require_last_push_approval = true`. +- **Required linear history** and **required conversation resolution** are enabled. +- **Force pushes and branch deletion are disabled**. +- **No direct pushes** to `main`. All changes go through a pull request. + +Branch protection has **no bypass actors** (`bypass_pull_request_allowances` is empty), so +there is no role-based route around the review requirement. That is a separate control from +the tag-creation bypass described under Tag immutability; the two must not be conflated. + +A pull request whose author is a CODEOWNER requires approval from another current +CODEOWNER. Self-approval and merges without a current independent approval are not +permitted. After the current head has that approval and all gates pass, an eligible +author or maintainer may perform the normal merge. + +## Tag immutability + +Release tags matching `refs/tags/v*` are covered by two active rulesets, and their bypass +posture is **not** symmetric: + +- `Protect stable release tags` (`21888356`) blocks `update` and `deletion`, and has **no + bypass actors**. No role, repository admin included, can move or delete a published `v*` + tag. +- `Restrict stable release tag creation` (`21899500`) blocks `creation`, and has **exactly + one** bypass actor: repository role `admin` (`repositoryRoleDatabaseId` 5), + `bypass_mode: always`. A repository admin can cut a `v*` tag directly. + +That asymmetry is the point. Creation stays reachable so a release can always be cut by an +admin, while published tags stay immutable for everyone, admins included. Immutability is +enforced by `21888356`, not by `21899500`. + +Read both from the individual ruleset endpoint, `GET /repos/{owner}/{repo}/rulesets/{id}`. +The collection endpoint `GET /repos/{owner}/{repo}/rulesets` renders `bypass_actors` as +`null` for every ruleset, which is what caused an earlier revision of this section to +record "neither of which has bypass actors" — false when written, and the reason the +per-ruleset read is called out here. GraphQL's `repositoryRoleName` on +`RepositoryRulesetBypassActor` returns the role name directly and is the clearest check. + +Rulesets do expose `created_at` and `updated_at` on the individual endpoint, so the pair is +orderable: `21888356` was created at `2026-08-31T00:34:47Z`, roughly four hours before +`21899500` at `2026-08-31T04:47:11Z`, which was itself updated 54 seconds later at +`04:48:05Z`. + +`v0.1.1` is the empirical proof of the admin creation bypass. The annotated tag `v0.1.1` +(tag object `c2ecc1c49ff8bbe13b9d7800bc910e4b7ac99b74`, pointing at commit +`cc727db0bc72655f299166de1f60756f5c686cc7`) is tagged `2026-08-31T06:32:10Z` — one hour +and forty-five minutes after the creation restriction became active, by a repository admin. + +- A release tag, once created, **must not** be moved or deleted. Force-moving a tag to + paper over a broken release destroys the provenance that a release tag exists to + provide. This is mechanically enforced against every role. +- If a release is broken, cut a **new patch tag** (`v0.1.1`) from a fixed commit. Do not + retag `v0.1.0`. +- Published tags must not be moved, deleted, or reused, including during a security + incident. +- Tag **creation** is restricted to repository admins by `21899500`. Cutting a release tag + is therefore an admin action that needs no ruleset change, and the admin who cuts it is + accountable for the release-cut review requirements in the next section. + +## Release-cut pull requests + +A release cut (a PR that bumps the version, updates a changelog, or otherwise +prepares a release) is held to a stricter bar than an ordinary PR: + +- The release-cut PR **must be approved by a non-author CODEOWNER**. The author's + own approval does not count, and branch protection has no bypass actors, so no + administrator route around this review exists. +- The release-cut PR must not be merged while any required status check is failing + or in-progress. "Merge now, fix the release workflow by retagging" is the exact + anti-pattern this charter prohibits. +- If a release workflow fails after the cut, the fix goes through a **new PR** that + is reviewed and merged, then a **new tag** is cut — not a retag of the broken one. + +## CODEOWNERS + +The roster is defined in [`.github/CODEOWNERS`](.github/CODEOWNERS) and is deliberately +not duplicated here. The rules below apply to whoever is listed there at the time of each +pull request: this section describes policy, not a name list, so an owner change cannot +leave the charter asserting a roster that no longer matches the file it defers to. + +- At least two owners are required so that a non-author approval is always satisfiable. + Two is a floor, not a target: one owner makes the review gate structurally + unsatisfiable, and two leave an availability bottleneck. +- When an owner authors a pull request or makes its last push, a different current owner + supplies the required independent approval. +- Changing the roster is a governance action taken in `.github/CODEOWNERS` through a + reviewed pull request that cites the owner decision authorizing it. Adding an owner + grants review authority only; it grants no tag creation, no role change, and no + relaxation of anything above. +- The charter's narrative is kept in step with that change. #124 revision R3 authorized + the third owner, which reached `main` through #138; the corresponding charter wording is + this section, landed as the documentation follow-up rather than in the same cycle. That + ordering is the defect this section exists to close, not a precedent to repeat. + +## Incident runbook + +If a release tag is found to point at a broken or compromised commit: + +1. Do not retag. Do not delete the tag. +2. For a security incident, open a security advisory and deprecate or yank affected + distribution channels where supported. Leave the published tag in place as + immutable evidence. +3. Open a fix PR. Get it reviewed and merged to `main` with a current non-author + approval. +4. Cut a new patch tag from the fixed `main` tip and publish the replacement release + from that new tag. Tag creation on `refs/tags/v*` is restricted to repository admins + by ruleset `21899500`, so this step is an admin action — it does not require changing + any ruleset, and no ruleset change should be made in order to perform it. From f1cc9eb06a9b3b7143331d53515e274ceb8e6c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8B=92=E5=B8=83=E6=9C=97-=E8=A9=B9=E5=A7=86=E6=96=AF?= <2986253039@qq.com> Date: Wed, 16 Sep 2026 21:56:05 +0800 Subject: [PATCH 15/31] fix(ci): resolve MinIO images from quay.io after the Docker Hub withdrawal (#209) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves #207. Every MinIO image reference in this repository now resolves from `quay.io` instead of Docker Hub. MinIO stopped publishing container images in October 2025 and removed the `minio/minio` and `minio/mc` repositories from Docker Hub entirely, so every reference here is dead. This is not a cosmetic dependency bump. `Validate Agent memory` → `HTTP, CLI and MCP lifecycle` **requires** the `e2e` Compose profile, and `HTTP, CLI and MCP lifecycle` is a **required status context on `main`**. So the registry withdrawal blocked *every* pull request in this repository from merging — not just the ones touching this stack — and no contributor could do anything about it. ## The failure, verbatim ```text minio Pulling minio Error pull access denied for minio/minio, repository does not exist or may require 'docker login': denied: requested access to the resource is denied postgres Interrupted ``` `postgres` reports `Interrupted` in the same step only as a consequence; the job dies during container startup, before any script under test is reached. I confirmed the outage was still live before writing this PR by re-running the failing job on the current head of #205 ([run 34857342711](https://github.com/bytefolk/mem/actions/runs/34857342711), attempt 4), which failed at `2026-09-15T05:52:24Z` with the identical error. ## The fix `quay.io` still serves the same images. Before changing anything I resolved both digests pinned in `docker-compose.test.yml` against quay.io: ```text quay.io/minio/minio @ sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e -> 200 (image manifest list) quay.io/minio/mc @ sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 -> 200 (image manifest list) ``` Both return their manifests, so **no image bytes change**. The digest-pinned test stack keeps its exact digests, the release-tagged deployment stack keeps its exact tags, and only the registry host differs. Same builds, different registry. ### Changed references | File | Service | From | To | | --- | --- | --- | --- | | `docker-compose.test.yml` | `minio` | `minio/minio:latest@sha256:14cea493…` | `quay.io/minio/minio:latest@sha256:14cea493…` | | `docker-compose.test.yml` | `minio-init` | `minio/mc:latest@sha256:a7fe349e…` | `quay.io/minio/mc:latest@sha256:a7fe349e…` | | `docker-compose.yml` | `minio` | `minio/minio:latest` | `quay.io/minio/minio:latest` | | `docker-compose.yml` | `minio-init` | `minio/mc:latest` | `quay.io/minio/mc:latest` | | `deploy/compose/compose.yaml` | `minio` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` | `quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z` | | `deploy/compose/compose.yaml` | `minio-init` | `minio/mc:RELEASE.2025-04-16T18-13-26Z` | `quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z` | | `deploy/compose/compose.yaml` | `minio-client` | `minio/mc:RELEASE.2025-04-16T18-13-26Z` | `quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z` | ### Why the non-CI files are in scope `docker-compose.yml` is the documented local development stack and `deploy/compose/compose.yaml` is the documented self-hosted single-node path. Both reference the same removed repositories. `deploy/compose/compose.yaml` deserves specific mention: **no workflow exercises it**, so it would have kept a broken reference indefinitely and failed on a cold host for an operator following `docs/DEPLOYMENT.md`. The `deploy/compose` `.env.example` / `backup.sh` / `restore.sh` files were checked and carry no image reference of their own. ## Deliberately not changed The `pgvector/pgvector` references. That repository is still present on Docker Hub, and the `PostgreSQL integration` job — which pulls `pgvector/pgvector:pg16@sha256:00ba258a…` straight from Docker Hub on the same runner image at the same time — was **green in all the runs that failed on MinIO**. That is the evidence that this is specific to the MinIO repositories rather than general registry egress, and it is why the fix is scoped to MinIO alone. See #207 for the full run-by-run comparison. ## How this verifies itself The failing job checks out the PR merge commit and runs `docker compose -f docker-compose.test.yml up -d --wait postgres minio`, so on this PR's own head it exercises the changed file directly. `HTTP, CLI and MCP lifecycle` going green here is the proof, and it is also the precondition that unblocks the rest of the queue. ## Out of scope - Removing the `minio/*` dependency entirely (e.g. moving to SeaweedFS) — a separate decision, and the Apache Flink project took that route while Apache Doris took this one. - Pinning `docker-compose.yml`'s floating `:latest` tags. Those are the local development stack, `scripts/validate_deploy.sh` only rejects mutable `latest` in the production deployment files (`deploy/`, `*/Dockerfile`), and tightening them is unrelated to this outage. --- CHANGELOG.md | 14 ++++++++++++++ deploy/compose/compose.yaml | 8 +++++--- docker-compose.test.yml | 6 ++++-- docker-compose.yml | 6 ++++-- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bdac57..51f7a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,20 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Every MinIO image reference in the test stack, the local development stack and + the self-hosted single-node Compose profile now resolves from `quay.io` instead + of Docker Hub. MinIO stopped publishing container images in October 2025 and + removed the `minio/minio` and `minio/mc` repositories from Docker Hub, so an + anonymous `docker compose up` fails with `pull access denied for minio/minio, + repository does not exist or may require 'docker login'`. Because `Validate + Agent memory` → `HTTP, CLI and MCP lifecycle` is a required status context, + that registry withdrawal blocked every pull request from merging (`#207`). + `quay.io` still serves the exact digests pinned in `docker-compose.test.yml`, + so no image bytes change: the digest-pinned test stack keeps its digests and + the release-tagged deployment stack keeps its tags — only the registry host + differs. `deploy/compose/compose.yaml` previously carried the same broken + reference, so the documented self-hosted path would have failed on a cold + host even though no workflow exercises it. - A configured URL that carries credentials in a shape `url.Parse` does not report as userinfo no longer reaches output. `admin:pw@host` parses as `Scheme="admin"` with the credential in `Opaque` and `User` unset, so an diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 29d3e17..e123d59 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -90,7 +90,9 @@ services: start_period: 5s minio: - image: minio/minio:RELEASE.2025-04-22T22-12-26Z + # MinIO withdrew its Docker Hub repositories in October 2025; quay.io serves + # the same release tags. + image: quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z restart: unless-stopped command: server /data --console-address :9001 environment: @@ -108,7 +110,7 @@ services: start_period: 10s minio-init: - image: minio/mc:RELEASE.2025-04-16T18-13-26Z + image: quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z restart: "no" depends_on: minio: @@ -217,7 +219,7 @@ services: start_period: 10s minio-client: - image: minio/mc:RELEASE.2025-04-16T18-13-26Z + image: quay.io/minio/mc:RELEASE.2025-04-16T18-13-26Z profiles: ["tools"] environment: MEM_S3_BUCKET: ${MEM_S3_BUCKET:-mem} diff --git a/docker-compose.test.yml b/docker-compose.test.yml index a411755..eea98e1 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -22,7 +22,9 @@ services: minio: profiles: ["e2e"] - image: minio/minio:latest@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e + # MinIO withdrew its Docker Hub repositories in October 2025; quay.io serves + # the same digest-pinned image. + image: quay.io/minio/minio:latest@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: mem @@ -40,7 +42,7 @@ services: minio-init: profiles: ["e2e"] - image: minio/mc:latest@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 + image: quay.io/minio/mc:latest@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 depends_on: minio: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 3642946..8c13718 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,7 +40,9 @@ services: retries: 10 minio: - image: minio/minio:latest + # MinIO withdrew its Docker Hub repositories in October 2025; quay.io serves + # the same image. + image: quay.io/minio/minio:latest container_name: mem-minio restart: unless-stopped command: server /data --console-address ":9001" @@ -60,7 +62,7 @@ services: # Bootstrap: create default bucket minio-init: - image: minio/mc:latest + image: quay.io/minio/mc:latest depends_on: minio: condition: service_healthy From 2c524ccbab41be20fae31a72a337b81e5d9120b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:13:30 +0800 Subject: [PATCH 16/31] ci(security): run CodeQL on fork pull requests (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What this changes One line deleted from `.github/workflows/bytefolk-security.yml` — the `if:` guard on the `codeql` job. Nothing else in the file or the repository changes. ```diff codeql: name: CodeQL (${{ matrix.language }}) - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-24.04 ``` Blob size goes 1988 -> 1867 bytes. The deleted line is exactly 121 bytes including its newline, so the arithmetic accounts for the whole delta: no whitespace, reordering or annotation change rode along. ## Why The guard's second clause compares the head repository to the base repository. It is false for every fork pull request, so the `codeql` job is skipped. GitHub does not evaluate a job's `name:` expression when the job is skipped by its `if:` guard. The skipped job publishes the raw template string as its check name, so fork PRs in this repository report a check literally named `CodeQL (${{ matrix.language }})`, which matches none of the three required contexts `CodeQL (go)`, `CodeQL (javascript-typescript)` and `CodeQL (python)`. This is not hypothetical. Two fork PRs by `@sun-970` were measured through `GET /repos/bytefolk/mem/commits/{sha}/check-runs` after their first-time workflow runs were approved: | PR | head | total | success | failed | skipped | | --- | --- | --- | --- | --- | --- | | #172 | `a29e9163` | 17 | 16 | 0 | 1 | | #181 | `4a25bd25` | 17 | 16 | 0 | 1 | The single skip in each is `CodeQL (${{ matrix.language }})`. Both are otherwise fully green and both report `mergeable: true`, yet neither can ever merge: the three CodeQL contexts are required and nothing the contributor does produces them. The un-interpolated name is a symptom of the skip, not a separate defect — on `bytefolk/digital-employee#250` the same expression interpolated to `CodeQL (javascript-typescript)` as soon as the job actually ran. ## Why deleting the guard rather than adding a `codeql-fork` job `bytefolk/digital-employee#250` solved this by adding a second job with the inverted guard. That job is identical to the baseline `codeql` job apart from the guard and the language matrix, so a second lane duplicates roughly 37 lines to express what deleting one line already says. The guard is also provably redundant for every trigger other than a fork PR: - `push`, `schedule`, `workflow_dispatch` — the first clause `github.event_name != 'pull_request'` is already true. - Same-repository `pull_request` — the second clause is already true. - Fork `pull_request` — the job was skipped; it now runs. **This is the only behavioural change.** ## Evidence that CodeQL actually works on a fork `pull_request` run `bytefolk/digital-employee#250` is a fork PR (head `PeterGuy326/digital-employee:feat/issue-245-memory-config`, sha `abb2d58b29710927742c828a510c419c7c53efb8`). Its `codeql-fork` job concluded `success` — check runs `102757987331` (84s) and `102757703884` (86s). That job declares `permissions: security-events: write` and contains **no** `continue-on-error` on the job or on any step. A `success` job conclusion therefore means every step succeeded, including `Analyze` — and `github/codeql-action/analyze` fails with HTTP 403 when `security-events: write` is absent. So SARIF upload from a fork `pull_request` run is confirmed, not assumed. Fork `pull_request` runs still receive no secrets and read-only `contents`. All ByteFolk repositories are public. ## Tradeoff, stated plainly After this change, fork PRs build untrusted code in a job holding `security-events: write`. The run still has no secrets and read-only `contents`, and every other required check in this repository (`Go`, `Worker`, `Web`, `PostgreSQL integration`, `Web memory and transfer acceptance`, `HTTP, CLI and MCP lifecycle`, `Workflow, scripts and Compose`) already builds that same untrusted code, so this adds no new class of exposure. It is the posture GitHub's own default CodeQL setup takes for public repositories. The alternatives were considered and rejected: - `pull_request_target` would hand a write-scoped token to a workflow run over attacker-influenced code. - Self-reporting the three contexts through the Statuses API would fabricate a required check that never ran. - Granting contributors organization membership does not help at all: the guard compares repositories, not author identity. ## What this does not touch - No `permissions:` block changes. The job keeps exactly `contents: read`, `actions: read`, `packages: read`, `security-events: write`. - No required status context, branch protection rule or ruleset is weakened or removed. - No action SHA, trigger, matrix entry, step or job other than the deleted line. - The pinned `# v4.37.4` annotations are deliberately left alone. They are the subject of bytefolk/.github#32 and #203, and this PR must stay disjoint from them. ## Interaction with #203 #203 edits this same file, but only the three `github/codeql-action/*` annotation lines at 58, 65 and 68, plus `bytefolk-scorecard.yml`. This PR deletes line 36. The hunks do not overlap, so the two 3-way-merge cleanly in either order. Neither needs rebasing because of the other. ## Checks `Workflow, scripts and Compose` runs `actionlint` over every workflow. Deleting a job-level conditional cannot introduce an actionlint finding; the resulting YAML keeps `name`, `runs-on`, `timeout-minutes`, `permissions`, `strategy` and `steps` on the `codeql` job. The release pin validators in that job (`validate_release_action_pins.sh`, `test_release_guards.sh`, `test_release_helpers_compat.sh`) all hardcode `release.yml` and never read this file. This branch is same-repository rather than a fork, so the PR's own three CodeQL contexts can go green here — otherwise the fix could not demonstrate itself. ## Follow-up The identical guard sits in `bytefolk/.github/workflow-templates/bytefolk-security.yml`, which is where every consumer copied it from. A separate template PR is filed so the fix propagates instead of regressing on the next template sync. Any consumer that added its own `codeql-fork` lane (`digital-employee`, via #250) must delete it once the template changes, otherwise two jobs publish the same check name. After this merges, #172 and #181 need a branch update rather than a plain re-run: for `pull_request` events GitHub takes the workflow YAML from the merge ref, while a re-run replays the workflow version captured when the run was created. Both are already `state: behind` under `strict: true`, so they need the update regardless. Refs bytefolk/.github#35 Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> --- .github/workflows/bytefolk-security.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/bytefolk-security.yml b/.github/workflows/bytefolk-security.yml index 88c2e66..c0226e7 100644 --- a/.github/workflows/bytefolk-security.yml +++ b/.github/workflows/bytefolk-security.yml @@ -33,7 +33,6 @@ jobs: codeql: name: CodeQL (${{ matrix.language }}) - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-24.04 timeout-minutes: 30 permissions: From f00fff78310c760b65ad18f7abdc44352206d0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:20:12 +0800 Subject: [PATCH 17/31] chore(ci): correct CodeQL pinned-version annotations (#203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Canonical requirement Refs https://github.com/bytefolk/.github/issues/32 - Canonical Issue URL: https://github.com/bytefolk/.github/issues/32 - Consumed revision: R1 - No automatic close keywords: acknowledged Decision reference: the initial R1 Issue body. It explicitly records that local candidates preceded this prospective publication record; no retrospective approval is claimed. ## Requirement trace | REQ/AC IDs | Changed files / domain | Tests or review evidence | |---|---|---| | REQ-001 / AC-001 | 4 exact-pinned version annotations | Exact expected-byte replacement PASS | | REQ-002 / AC-002 | 2 files in bytefolk/mem | Repository inventory PASS; aggregate 7 repositories, 13 files, 21 lines | | REQ-003 / AC-003 | Existing workflow content and modes | Parsed YAML and comment-stripped bytes identical | | REQ-004 / AC-004 | Current-head CI and independent review | Local independent replay recorded in the canonical R1 Issue linked above; hosted CI collected on head `f464f686` (19 of 20 checks succeed, see Validation); independent human review requested and still pending | ## File domains `.github/workflows/bytefolk-scorecard.yml` (47); `.github/workflows/bytefolk-security.yml` (58, 65, 68). Prepared parent / merge base: `2986fe38175f54d99f15dd38a498708c6ecd88cd` PR base at publication: `87db0dfe0507be2190fe2fdcce0e267be8224f4d`. Since that baseline `main` advanced by six commits through `3c13f04e` (#162, #160, #165, #188, #158, #204) — not only `web/package-lock.json` as previously stated here. None of them touched `.github/workflows/`, so the F9 workflow blobs and the PR diff are unchanged. The reviewed commit and original parent are preserved. Head: `f464f68636adc6bb5295c3818aa6654c46a3caad` — `d2a9ec5` plus one non-forced `Merge branch 'main'` commit (`f464f686`) that brought the branch up to `3c13f04e` so it is no longer `BEHIND`. Verified: `git rev-parse d2a9ec5:.github/workflows/bytefolk-scorecard.yml` and `...:bytefolk-security.yml` return the same blobs (`2058126c`, `48a507e0`) as at `f464f686`, and `git diff main...f464f686` is still exactly these 2 files, `+4/-4`. The comment-only payload is therefore byte-identical to the reviewed commit and the equality proof above holds on the current head. ## Scope and non-goals Correct only `# v4.37.4` to `# v4.37.9` on CodeQL uses-lines pinned to `cdf488f595d80d6e07e03d4674febd5ab45fa938`. The [official tag object](https://api.github.com/repos/github/codeql-action/git/tags/a35ac6e6798d72df5475948b28efb89edc2e19ca) resolves to that existing pin. Action SHAs, permissions, triggers, steps, matrices, other pins, and runtime code are unchanged. ## Validation - Exact commands: `ruby evidence/verify.rb --baseline` and `ruby evidence/verify.rb --committed` from the retained review packet; `git diff --check 2986fe38175f54d99f15dd38a498708c6ecd88cd d2a9ec5adde7931280076c729209f448a31ca25e` from this repository. - Observed counts/results: PASS 2/2 files and 4/4 replacements here; aggregate PASS 13/13 files and 21/21 replacements. Baseline intentionally exits 1 after detecting all 21 stale annotations; committed verification exits 0. - Check URLs: collected on head `f464f686` — 19 of 20 checks succeed. The single failure is [`HTTP, CLI and MCP lifecycle`](https://github.com/bytefolk/mem/actions/runs/34807101354/job/103861020551), whose log is `pull access denied for minio/minio` at ~13s: a container-image pull failure in an unrelated job. The same workflow was green on `main` at `3c13f04e`, and the identical failure is present on #198 and #199, so it is not caused by this comment-only change. Root-cause tracking is separate and open. The strict verifier checks the changed-file allowlist; exact old blobs and line inventory; complete expected-byte replacement; absence of stale target annotations; parsed YAML equality; comment-stripped byte equality and SHA-256 digests; whitespace and unchanged modes; one commit with the exact parent; and clean worktrees with no untracked files. All passed. The independent replay is recorded in canonical R1. The verifier and inventory are retained outside repository commits. | ID | REQ/AC | Observable acceptance criterion | Command or manual steps | Environment | Expected | Observed | Status | |---|---|---|---|---|---|---|---| | V1 | AC-001, AC-002, AC-003 | Exact annotations with executable YAML unchanged | `ruby evidence/verify.rb --committed` | Ruby 2.6.10, Psych 3.1.0, isolated review packet | Exact scoped replacements and equality | 2/2 files; 4/4 lines; all invariants pass | PASS | | V2 | AC-004 | Hosted checks on this exact head | Inspect this PR's checks at `f464f686` | GitHub Actions | Applicable checks succeed | 19 of 20 succeed; `HTTP, CLI and MCP lifecycle` fails on `pull access denied for minio/minio` (infra, unrelated job, also failing on #198/#199, green on `main`) | PARTIAL | ## Security and compatibility Documentation annotation only. No dependencies, permissions, credentials, data flows, or runtime behavior change. The diff and commit identity were inspected for public-safe content. No CHANGELOG entry or behavior-documentation update is needed because only explanatory comments change. ## Known limitations Runtime suites, build, coverage, and dependency audits were not rerun for this comment-only change; no runtime test result is claimed. Hosted CI is separate from local equality proof. Two limits now apply: (1) the strict verifier's `one commit with the exact parent` invariant describes the reviewed payload commit `d2a9ec5`, not the current branch shape, which carries two additional `Merge branch 'main'` commits; (2) this PR is **not merge-ready yet** — repository `AGENTS.md` step 6 requires passing CI *and* an approval from someone other than the author, and `HTTP, CLI and MCP lifecycle` is red on the unrelated `minio` pull, so the green-CI half is unmet until that infrastructure failure is fixed. ## Risk and rollback Low-risk annotation correction. Roll back through an ordinary revert of this single commit. There is no migration or release action. ## Product review handoff - Implementation/publication owner: @PeterGuy326 - Automated pre-review result: independent local replay recorded in R1; no human approval implied. - Human final review: PENDING; no human review requested by this publication. - Merge ledger owner: @PeterGuy326 - Product reviewer: @PeterGuy326 - Milestone or release packet: N/A: bounded documentation annotation maintenance - Merge, CI, release, and model judgment do not accept or close the Issue: acknowledged ## Maintenance update (2026-09-14, @waterbro-8) Records written by the maintainer account, not by the implementation owner: - `f464f686 Merge branch 'main'` was pushed to this head branch (non-forced, `main` at `3c13f04e` is an ancestor of the head) to clear the `BEHIND` state this PR's own body said blocked merging. No workflow file content changed: both blobs are identical to `d2a9ec5`. - The stale facts above were corrected in place: the recorded head SHA, the "Main advanced only `web/package-lock.json` in PR #192" claim, the `NOT VERIFIED` hosted-CI rows, and the "this is a draft, not merge-ready" note. - This PR was marked ready for review and an independent review was requested. The maintainer account that pushed the merge commit did **not** approve it: `AGENTS.md` step 6 requires an approval from someone other than the author, and a commit author on the head cannot supply that approval for their own push. `@PeterGuy326` remains implementation and merge-ledger owner. Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> --- .github/workflows/bytefolk-scorecard.yml | 2 +- .github/workflows/bytefolk-security.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/bytefolk-scorecard.yml b/.github/workflows/bytefolk-scorecard.yml index bca8aa4..2058126 100644 --- a/.github/workflows/bytefolk-scorecard.yml +++ b/.github/workflows/bytefolk-scorecard.yml @@ -44,6 +44,6 @@ jobs: retention-days: 14 - name: Upload Scorecard results - uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + uses: github/codeql-action/upload-sarif@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: sarif_file: results.sarif diff --git a/.github/workflows/bytefolk-security.yml b/.github/workflows/bytefolk-security.yml index c0226e7..724b46d 100644 --- a/.github/workflows/bytefolk-security.yml +++ b/.github/workflows/bytefolk-security.yml @@ -54,16 +54,16 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} queries: security-extended - name: Autobuild Go if: ${{ matrix.language == 'go' }} - uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + uses: github/codeql-action/autobuild@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 - name: Analyze - uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.4 + uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: category: /language:${{ matrix.language }} From 0a0b55f1175ffc2db475e09fec3951c1d866c3d1 Mon Sep 17 00:00:00 2001 From: sun-970 <3843544764@qq.com> Date: Thu, 17 Sep 2026 08:27:58 +0800 Subject: [PATCH 18/31] docs(goal): correct four stale status statements for merged capabilities (#181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes #179. `GOAL.md` §5 and §6 contain four status statements that list capabilities as missing when they are already merged and reachable on `main`. This PR corrects them to match what the code actually supports. ## Changes ### §5 — 现状与愿景的偏差 table - **跨设备恢复**: Separate `merge_conservative` (done, `merge.go` + `0023_workspace_import_merge.sql`) from 增量同步 and 断点上传 (not done). The old wording made all three look missing when only two are. - **人类可视化**: correction/supersede relations are merged (`0022_memory_relations.sql`, `relation.go`); only audit history remains open. - **数据可移植性**: `merge_conservative` import is merged; update "当前服务只支持 fresh restore" to reflect that merge is now supported. ### §6 — 近期优先级 - **P1**: Split `merge_conservative` (checked) from 增量包/断点上传 (still open). Previously one unchecked item bundled done and undone work together. - **P2**: Check off correction/supersede, 导入历史 and 权限管理界面 — all shipped via PRs #90/#95, #102, #100. ## Evidence Each correction is backed by source-level evidence (file presence + migration content) as documented in #179. No runtime verification was performed; see issue's "Evidence level: E2 — source-level" note. ## Test plan - [ ] Review each corrected line against the cited source files. - [ ] Confirm §5 rows now separate done from not-done within each capability. - [ ] Confirm §6 checkboxes match the merged PR list. Co-authored-by: 勒布朗-詹姆斯 <2986253039@qq.com> --- GOAL.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/GOAL.md b/GOAL.md index ef7d4bd..8515d46 100644 --- a/GOAL.md +++ b/GOAL.md @@ -92,9 +92,9 @@ mem 只有一份数据和能力内核,但面向两类使用者: | 跨 Agent 记忆 | `remember/context`、出处、幂等、反馈、归档、恢复和遗忘控制闭环已实现 | **MVP 对齐** | | 标准任务交接 | `mem.handoff` v1、不可变 checkpoint、CAS、resume、哈希引用和缺失项报告已实现 | **MVP 对齐** | | Claude Code / Codex 迁移 | 以 Claude Code / Codex 身份隔离的两个独立 Token 已通过真实 HTTP/PostgreSQL 写入与只读恢复验收;两端有同一个标准 MCP adapter 的接入说明 | **MVP 对齐;仍需真实宿主进程的发布级 smoke** | -| 跨设备恢复 | 同一部署可登录 workspace 继续使用;跨部署可导出 `.membundle` 并 `fresh` 导入空 workspace,带完整性校验、幂等 ledger 和结构化冲突 | **部分对齐:尚无 merge、增量同步和断点上传** | -| 人类可视化 | Web 已覆盖 Drive、Search、Tasks、checkpoint/Resume、Memories 生命周期与 Workspace Transfer | **MVP 对齐:尚缺 correction/supersede 与完整审计历史** | -| 数据可移植性 | workspace bundle v1 有开放 schema、七类索引、payload/blob checksum、依赖校验和真实数据库 round-trip | **MVP 对齐:当前服务只支持 fresh restore** | +| 跨设备恢复 | 同一部署可登录 workspace 继续使用;跨部署可导出 `.membundle` 并 `fresh` 导入空 workspace,带完整性校验、幂等 ledger 和结构化冲突;`merge_conservative` 已实现 | **部分对齐:merge 已完成,尚无增量同步和断点上传** | +| 人类可视化 | Web 已覆盖 Drive、Search、Tasks、checkpoint/Resume、Memories 生命周期与 Workspace Transfer;correction/supersede 关系已实现 | **MVP 对齐:尚缺完整审计历史** | +| 数据可移植性 | workspace bundle v1 有开放 schema、七类索引、payload/blob checksum、依赖校验和真实数据库 round-trip;`merge_conservative` 导入已实现 | **MVP 对齐:尚无增量同步和断点上传** | | 自然语言搜图 | 原始图片字节→512 维视觉向量→文本塔查询→可回原件的链路已实现;真实英文固定集通过 | **链路对齐、质量未完全对齐:默认模型中文固定集未通过** | 因此,项目现在不再只是“可视化的 Agent Memory / AI 搜索网盘”,而是已经具备 @@ -120,13 +120,14 @@ mem 只有一份数据和能力内核,但面向两类使用者: - [x] 定义包含 manifest、内容哈希、schema 版本和依赖关系的 `.membundle` v1。 - [x] 实现 API / CLI / Web 的 workspace export 与空目标 `fresh` import。 - [x] 实现导入前校验、幂等 ledger、冲突明细、失败补偿和导入后重新索引。 -- [ ] 实现 `merge_conservative`、增量包、断点上传与完整本地同步盘体验。 +- [x] 实现 `merge_conservative`。 +- [ ] 实现增量包、断点上传与完整本地同步盘体验。 ### P2 — 让全部 Agent 数据可见可控 - [x] 在 Web UI 增加记忆、任务交接、来源、版本、Resume 与迁移视图。 - [x] 提供反馈、置顶、归档、恢复、确认遗忘和 workspace 导出/导入。 -- [ ] 提供不可变 correction/supersede、导入历史和更完整的权限管理界面。 +- [x] 提供不可变 correction/supersede、导入历史和更完整的权限管理界面。 ### 持续主线 — 自然语言搜图与多模态召回 From ced64a13c022c03376fd8c46a3d9c5d327ccd085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=AE=E9=9B=A8?= <47820304+PeterGuy326@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:11:31 +0800 Subject: [PATCH 19/31] fix(web): unify shared tokens, alignment and empty states (#212) ## Problem and result Refs #211 (revision r2) and bytefolk/design-system#29. The Web client had faint light-theme labels, inconsistent control colors and broadly centered reading content. It now derives its palette from the shared design-system source and aligns content by purpose: names/forms/headings start-align, comparable numeric columns and trailing actions end-align, and button contents and complete empty states center. Narrow layouts keep controls reachable. ## Changes - Add a zero-dependency token generator using an unmodified, licensed snapshot of design-system commit `910456901dda74da4d5b0320cd03d36ad18650b0`; verify its SHA-256 and generated output before every Web build. Preserve alpha and derive readable foreground/solid-action pairs from those source hues. - Keep React 19 and existing primitives; add no runtime dependency or peer-range change. A local empty-state adapter follows the shared title/description/action scale. - Restore reading alignment across navigation, files, filters, forms, cards and dialogs; keep localized confirmation labels visible when omitted by callers. - Use a device-width viewport, wrap provider/detail actions, and keep permission tables locally scrollable on phones. Theme browser chrome follows computed tokens. - Update Unreleased changelog. API, routes, storage and authorization behavior are unchanged. ## Validation With locked dependencies (`cd web && npm ci`), `make test-web` passed: type/lint/build and the existing localization, theme, enrichment, memory, managed embedding and transfer acceptance. Unit tests: 7 files / 69 passed; root independently reran the 2 confirmation regressions. The token check verifies 334 generated/composited pairs at >=4.5:1. Actual browser acceptance captured 82 states across both themes at desktop and 390px touch-mobile widths, covering populated/empty views, dialogs, menus, file/memory/task details, providers, permissions and transfer. All 4,758 sampled visible-text pairs met the selected contrast threshold, with no JavaScript errors or unexpected document overflow. The mobile permission table was actually swiped to its trailing action and the confirmation opened. Root independently inspected the running Web client. A separate source/render review caught and verified the failed-thumbnail badge fix (9.23:1 light /10.14:1 dark). A further12 primary/danger/disabled normal+hover samples reached at least5.55:1; true touch scrolling also kept file-list names and numeric columns reachable. This is fixture-based UI acceptance: all API responses came from MSW; it is not a live-backend integration result. Private screenshots remain local. ## CI dependency and review status Keep this PR **Draft** until required current-head CI passes. The preceding head's HTTP/CLI/MCP lifecycle check failed before application tests because the pinned MinIO image could not be pulled from Docker Hub. The separate fix is #209 (issue #207); it is not copied into this UI diff. Other green checks do not waive that dependency. Current-head results must be read independently after this push. No deployment or formal human approval is included. Revert the scoped commits to roll back; no migration is required. ## Current-head hosted result All checks have finished on `7d941998167c4c6dadd27be348d9b96e1ec28e03`. The only failing check is [HTTP, CLI and MCP lifecycle](https://github.com/bytefolk/mem/actions/runs/35047528915/job/104640536818); all other check entries succeeded, including Web and Web memory/transfer acceptance. The failing job stops while starting isolated dependencies: `minio/minio` reports pull access denied before the application tests. This matches the separate registry fix in #209. The PR remains Draft pending that dependency and a fresh successful CI run. Totoro received the review bundle and explicit blocker status, with successful message delivery verified. This is a handoff, not formal approval. --- CHANGELOG.md | 7 + web/design-system/LICENSE | 201 ++++++++ web/design-system/README.md | 37 ++ web/design-system/tokens.json | 483 ++++++++++++++++++ web/index.html | 8 +- web/localization-acceptance.mjs | 2 +- web/package.json | 5 +- web/scripts/design-tokens.mjs | 136 +++++ web/src/components/explorer/ContextMenu.tsx | 6 +- web/src/components/explorer/FileGrid.tsx | 20 +- web/src/components/explorer/FileList.tsx | 9 +- web/src/components/layout/TopBar.tsx | 2 +- .../memory/CreateRelationDialog.tsx | 8 +- .../components/memory/ForgetMemoryDialog.tsx | 8 +- web/src/components/memory/MemoryDetail.tsx | 4 +- web/src/components/memory/MemoryFilters.tsx | 10 +- web/src/components/ui/Badge.tsx | 4 +- web/src/components/ui/Button.tsx | 6 +- web/src/components/ui/Card.tsx | 4 +- web/src/components/ui/ConfirmDialog.test.tsx | 38 ++ web/src/components/ui/ConfirmDialog.tsx | 17 +- web/src/components/ui/EmptyState.tsx | 33 +- web/src/hooks/useTheme.tsx | 8 +- web/src/pages/CheckpointDetailPage.tsx | 6 +- web/src/pages/ExplorerPage.tsx | 33 +- web/src/pages/FileDetailPage.tsx | 12 +- web/src/pages/MemoriesPage.tsx | 8 +- web/src/pages/PermissionsPage.tsx | 24 +- web/src/pages/ProvidersPage.tsx | 10 +- web/src/pages/SearchPage.tsx | 6 +- web/src/pages/TaskDetailPage.tsx | 8 +- web/src/pages/TasksPage.tsx | 6 +- web/src/pages/TransferPage.tsx | 18 +- web/src/styles/design-tokens.generated.css | 92 ++++ web/src/styles/globals.css | 60 +-- web/tailwind.config.js | 52 +- web/theme-acceptance.mjs | 4 +- 37 files changed, 1171 insertions(+), 224 deletions(-) create mode 100644 web/design-system/LICENSE create mode 100644 web/design-system/README.md create mode 100644 web/design-system/tokens.json create mode 100644 web/scripts/design-tokens.mjs create mode 100644 web/src/components/ui/ConfirmDialog.test.tsx create mode 100644 web/src/styles/design-tokens.generated.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7a23..7a5e985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,13 @@ The project publishes 0.x prerelease versions; a stable release line is not yet ### Fixed +- Follow the shared design language for reading, numeric and action alignment; generate the existing Web color variables from a pinned design-system token snapshot, and use a single consistent empty-state pattern. Refs #211. + +- Improve web caption and status contrast in both themes, including tinted danger + buttons, and center action labels, context menus, badges, dialog prompts, and + overview/detail headings. Restore localized cancel/confirm labels when a + confirmation dialog caller omits custom action text. + - Every MinIO image reference in the test stack, the local development stack and the self-hosted single-node Compose profile now resolves from `quay.io` instead of Docker Hub. MinIO stopped publishing container images in October 2025 and diff --git a/web/design-system/LICENSE b/web/design-system/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/web/design-system/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/web/design-system/README.md b/web/design-system/README.md new file mode 100644 index 0000000..1c448bf --- /dev/null +++ b/web/design-system/README.md @@ -0,0 +1,37 @@ +# Shared design token bridge + +`tokens.json` is an unmodified snapshot of +[`bytefolk/design-system` at 910456901dda74da4d5b0320cd03d36ad18650b0](https://github.com/bytefolk/design-system/blob/910456901dda74da4d5b0320cd03d36ad18650b0/tokens/design-tokens.json). +The upstream Apache-2.0 license is included as `LICENSE`. + +The shared JSON owns the palette. Do not edit the snapshot or generated CSS by +hand. To update it, copy the JSON from a reviewed upstream commit, update the +revision and expected SHA-256 in `../scripts/design-tokens.mjs`, regenerate, and review both themes. +The CSS header records the commit and content SHA-256. + +```sh +cd web +npm run tokens:generate +npm run tokens:check +``` + +`tokens:check` runs before every Web build. The zero-dependency generator maps +the default profile to mem's existing RGB variable API, preserves alpha, and +uses shared font/shadow roles. Human actions are blue; `ai` is reserved for AI +affordances. The user's saved light/dark preference is retained. + +Upstream base hues include decorative contrast exceptions. For actual small +text, the adapter raises source alpha or adjusts an opaque source hue toward +the theme's foreground until it reaches 4.5:1 on all five source surfaces. +Semantic foregrounds also meet this ratio on up to 30% tinted state backgrounds. Solid normal/hover backgrounds +are derived from upstream primary/hover hues and paired with the upstream +primary foreground. Generation checks 334 text/background pairs; actual +browser acceptance additionally checks compositing and control states. + +mem currently uses React 19 and local primitives; the published shared facade +expects React 18. This bridge deliberately adds no runtime dependency or React +migration. The local `EmptyState` follows the shared `ui-empty-state` structure +and spacing; it can be replaced by a compatible shared release later. Page, +card, dialog and form content starts at the reading edge; numeric comparison +columns and trailing actions align to the end; buttons, badges, tabs and whole +empty panels center their content. Business routes and actions stay in mem. diff --git a/web/design-system/tokens.json b/web/design-system/tokens.json new file mode 100644 index 0000000..cccf5a2 --- /dev/null +++ b/web/design-system/tokens.json @@ -0,0 +1,483 @@ +{ + "$schema": "https://design-tokens.github.io/community-group/format/", + "name": "@fullstack-ai-infra/ui", + "description": "Ant Design aligned semantic design tokens v3 (light+dark). Values extracted from antd@5 defaultAlgorithm/darkAlgorithm on 2026-08-24 by Design Lead; AA deviations documented in docs/org-workbench-design-language.md. Single token source per ADR 0002: src/styles/tokens.css is generated via scripts/generate-tokens-css.mjs.", + "tokens": { + "color": { + "canvas": { + "$type": "color", + "$value": { + "light": "#f5f5f5", + "dark": "#000000" + } + }, + "canvas-subtle": { + "$type": "color", + "$value": { + "light": "#fafafa", + "dark": "#141414" + } + }, + "navigation": { + "$type": "color", + "$value": { + "light": "#f0f0f0", + "dark": "#1f1f1f" + } + }, + "navigation-hover": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#15325b" + } + }, + "surface": { + "$type": "color", + "$value": { + "light": "#ffffff", + "dark": "#141414" + } + }, + "surface-raised": { + "$type": "color", + "$value": { + "light": "#ffffff", + "dark": "#1f1f1f" + } + }, + "surface-inset": { + "$type": "color", + "$value": { + "light": "#fafafa", + "dark": "#1d1d1d" + } + }, + "foreground": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.88)", + "dark": "rgba(255, 255, 255, 0.85)" + } + }, + "foreground-muted": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.65)", + "dark": "rgba(255, 255, 255, 0.65)" + } + }, + "foreground-subtle": { + "$type": "color", + "$value": { + "light": "rgba(0, 0, 0, 0.45)", + "dark": "rgba(255, 255, 255, 0.45)" + } + }, + "border": { + "$type": "color", + "$value": { + "light": "#d9d9d9", + "dark": "#424242" + } + }, + "border-strong": { + "$type": "color", + "$value": { + "light": "#bfbfbf", + "dark": "#595959" + } + }, + "primary": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "primary-hover": { + "$type": "color", + "$value": { + "light": "#4096ff", + "dark": "#3c89e8" + } + }, + "primary-foreground": { + "$type": "color", + "$value": "#ffffff" + }, + "primary-soft": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#15325b" + } + }, + "ai": { + "$type": "color", + "$value": { + "light": "#722ed1", + "dark": "#642ab5" + } + }, + "ai-strong": { + "$type": "color", + "$value": { + "light": "#531dab", + "dark": "#854eca" + } + }, + "ai-hover": { + "$type": "color", + "$value": { + "light": "#9254de", + "dark": "#854eca" + } + }, + "ai-foreground": { + "$type": "color", + "$value": "#ffffff" + }, + "ai-soft": { + "$type": "color", + "$value": { + "light": "#f9f0ff", + "dark": "#301c4d" + } + }, + "info": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "info-soft": { + "$type": "color", + "$value": { + "light": "#e6f4ff", + "dark": "#111a2c" + } + }, + "success": { + "$type": "color", + "$value": { + "light": "#52c41a", + "dark": "#49aa19" + } + }, + "success-strong": { + "$type": "color", + "$value": { + "light": "#237804", + "dark": "#95de64" + } + }, + "success-soft": { + "$type": "color", + "$value": { + "light": "#f6ffed", + "dark": "#162312" + } + }, + "warning": { + "$type": "color", + "$value": { + "light": "#faad14", + "dark": "#d89614" + } + }, + "warning-strong": { + "$type": "color", + "$value": { + "light": "#ad4e00", + "dark": "#f8ce5b" + } + }, + "warning-soft": { + "$type": "color", + "$value": { + "light": "#fffbe6", + "dark": "#2b2111" + } + }, + "danger": { + "$type": "color", + "$value": { + "light": "#ff4d4f", + "dark": "#dc4446" + } + }, + "danger-strong": { + "$type": "color", + "$value": { + "light": "#cf1322", + "dark": "#e84749" + } + }, + "danger-soft": { + "$type": "color", + "$value": { + "light": "#fff2f0", + "dark": "#2c1618" + } + }, + "overlay": { + "$type": "color", + "$value": "rgba(0, 0, 0, 0.45)" + }, + "focus": { + "$type": "color", + "$value": { + "light": "#1677ff", + "dark": "#1668dc" + } + }, + "selection": { + "$type": "color", + "$value": { + "light": "#bae0ff", + "dark": "#15417e" + } + } + }, + "fontFamily": { + "font-sans": { + "$type": "fontFamily", + "$value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'" + }, + "font-mono": { + "$type": "fontFamily", + "$value": "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace" + } + }, + "fontSize": { + "text-xs": { + "$type": "fontSize", + "$value": "12px" + }, + "text-sm": { + "$type": "fontSize", + "$value": "14px" + }, + "text-base": { + "$type": "fontSize", + "$value": "16px" + }, + "text-lg": { + "$type": "fontSize", + "$value": "20px" + }, + "text-xl": { + "$type": "fontSize", + "$value": "24px" + }, + "text-2xl": { + "$type": "fontSize", + "$value": "30px" + } + }, + "lineHeight": { + "leading-tight": { + "$type": "lineHeight", + "$value": "1.3333" + }, + "leading-normal": { + "$type": "lineHeight", + "$value": "1.5714" + } + }, + "dimension": { + "space-1": { + "$type": "dimension", + "$value": "0.25rem" + }, + "space-2": { + "$type": "dimension", + "$value": "0.5rem" + }, + "space-3": { + "$type": "dimension", + "$value": "0.75rem" + }, + "space-4": { + "$type": "dimension", + "$value": "1rem" + }, + "space-5": { + "$type": "dimension", + "$value": "1.25rem" + }, + "space-6": { + "$type": "dimension", + "$value": "1.5rem" + }, + "space-8": { + "$type": "dimension", + "$value": "2rem" + }, + "space-10": { + "$type": "dimension", + "$value": "2.5rem" + }, + "space-12": { + "$type": "dimension", + "$value": "3rem" + }, + "rail-width": { + "$type": "dimension", + "$value": "4.5rem" + }, + "sidebar-width": { + "$type": "dimension", + "$value": "16rem" + }, + "sidebar-wide": { + "$type": "dimension", + "$value": "18rem" + }, + "topbar-height": { + "$type": "dimension", + "$value": "3.75rem" + } + }, + "borderRadius": { + "radius-sm": { + "$type": "borderRadius", + "$value": "4px" + }, + "radius-md": { + "$type": "borderRadius", + "$value": "6px" + }, + "radius-lg": { + "$type": "borderRadius", + "$value": "8px" + }, + "radius-xl": { + "$type": "borderRadius", + "$value": "8px" + }, + "radius-full": { + "$type": "borderRadius", + "$value": "9999px" + } + }, + "boxShadow": { + "shadow-sm": { + "$type": "boxShadow", + "$value": "0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)" + }, + "shadow-md": { + "$type": "boxShadow", + "$value": "0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)" + }, + "shadow-lg": { + "$type": "boxShadow", + "$value": "0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05)" + } + }, + "duration": { + "duration-fast": { + "$type": "duration", + "$value": "0.1s" + }, + "duration-normal": { + "$type": "duration", + "$value": "0.2s" + } + }, + "timingFunction": { + "ease": { + "$type": "timingFunction", + "$value": "cubic-bezier(0.645, 0.045, 0.355, 1)" + } + } + }, + "profiles": { + "mint": { + "$description": "Compact high-contrast mint profile for dark-first workbenches. Product applications may label this profile independently.", + "tokens": { + "color": { + "canvas": { "$value": { "light": "#f6f8f7", "dark": "#0a0c10" } }, + "canvas-subtle": { "$value": { "light": "#eff3f1", "dark": "#101318" } }, + "navigation": { "$value": { "light": "#eff3f1", "dark": "#101318" } }, + "navigation-hover": { "$value": { "light": "#e5ebe8", "dark": "#181d22" } }, + "surface": { "$value": { "light": "#ffffff", "dark": "#14171d" } }, + "surface-raised": { "$value": { "light": "#ffffff", "dark": "#1a1e25" } }, + "surface-inset": { "$value": { "light": "#f1f4f2", "dark": "#101318" } }, + "foreground": { "$value": { "light": "#191e24", "dark": "#e9edf2" } }, + "foreground-muted": { "$value": { "light": "#57606b", "dark": "#a3abb8" } }, + "foreground-subtle": { "$value": { "light": "#636d78", "dark": "#7d8694" } }, + "border": { "$value": { "light": "#dbe2df", "dark": "#232830" } }, + "border-strong": { "$value": { "light": "#bfcac4", "dark": "#343b45" } }, + "primary": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "primary-hover": { "$value": { "light": "#085a42", "dark": "#2ee9a8" } }, + "primary-foreground": { "$value": { "light": "#ffffff", "dark": "#04150f" } }, + "primary-soft": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } }, + "ai": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "ai-strong": { "$value": { "light": "#07563f", "dark": "#2ee9a8" } }, + "ai-hover": { "$value": { "light": "#085a42", "dark": "#2ee9a8" } }, + "ai-foreground": { "$value": { "light": "#ffffff", "dark": "#04150f" } }, + "ai-soft": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } }, + "info": { "$value": { "light": "#1b7fb5", "dark": "#5cc8e8" } }, + "info-soft": { "$value": { "light": "#e6f4fb", "dark": "#12313b" } }, + "success": { "$value": { "light": "#266b4a", "dark": "#78c89c" } }, + "success-strong": { "$value": { "light": "#1e5739", "dark": "#a3ddb8" } }, + "success-soft": { "$value": { "light": "#eaf5ef", "dark": "#173526" } }, + "warning": { "$value": { "light": "#7d4f0f", "dark": "#f0b563" } }, + "warning-strong": { "$value": { "light": "#6b420b", "dark": "#f6cf95" } }, + "warning-soft": { "$value": { "light": "#fff4df", "dark": "#352816" } }, + "danger": { "$value": { "light": "#a83232", "dark": "#f08d8d" } }, + "danger-strong": { "$value": { "light": "#8f2525", "dark": "#ffb1b1" } }, + "danger-soft": { "$value": { "light": "#fff0f0", "dark": "#3b2226" } }, + "overlay": { + "$value": { "light": "rgba(20, 30, 26, 0.35)", "dark": "rgba(0, 0, 0, 0.65)" } + }, + "focus": { "$value": { "light": "#0a6b4e", "dark": "#19d89b" } }, + "selection": { "$value": { "light": "#e2f7ef", "dark": "#0d2a22" } } + }, + "fontFamily": { + "font-sans": { + "$value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif" + }, + "font-mono": { + "$value": "'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace" + } + }, + "fontSize": { + "text-xs": { "$value": "11px" }, + "text-sm": { "$value": "12px" }, + "text-base": { "$value": "13px" }, + "text-lg": { "$value": "17px" }, + "text-xl": { "$value": "22px" }, + "text-2xl": { "$value": "28px" } + }, + "lineHeight": { + "leading-tight": { "$value": "1.3" }, + "leading-normal": { "$value": "1.6" } + }, + "borderRadius": { + "radius-sm": { "$value": "6px" }, + "radius-md": { "$value": "8px" }, + "radius-lg": { "$value": "13px" }, + "radius-xl": { "$value": "16px" } + }, + "boxShadow": { + "shadow-sm": { "$value": "0 1px 2px 0 rgba(20, 21, 27, 0.03)" }, + "shadow-md": { + "$value": "0 4px 16px 0 rgba(20, 21, 27, 0.07), 0 1px 4px 0 rgba(20, 21, 27, 0.03)" + }, + "shadow-lg": { "$value": "0 16px 48px 0 rgba(20, 21, 27, 0.14)" } + }, + "duration": { + "duration-fast": { "$value": "0.12s" }, + "duration-normal": { "$value": "0.16s" } + }, + "timingFunction": { + "ease": { "$value": "cubic-bezier(0.22, 0.61, 0.36, 1)" } + } + } + } + } +} diff --git a/web/index.html b/web/index.html index e0268bc..a83cc1f 100644 --- a/web/index.html +++ b/web/index.html @@ -3,9 +3,9 @@ - + - + mem diff --git a/web/localization-acceptance.mjs b/web/localization-acceptance.mjs index b322272..96687c1 100644 --- a/web/localization-acceptance.mjs +++ b/web/localization-acceptance.mjs @@ -173,7 +173,7 @@ try { console.log('✓ unknown managed-embedding errors use the selected locale'); await page.goto(`${baseURL}/search`, { waitUntil: 'domcontentloaded' }); - await page.getByRole('heading', { name: '搜索' }).waitFor(); + await page.getByRole('heading', { name: '搜索', level: 1, exact: true }).waitFor(); await page.getByRole('button', { name: '草地上的金毛' }).waitFor(); assert.equal(await page.evaluate(() => window.__documentLangAtInteractive), 'zh-CN'); diff --git a/web/package.json b/web/package.json index 6a66d95..a005d33 100644 --- a/web/package.json +++ b/web/package.json @@ -21,7 +21,10 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b --noEmit", + "tokens:generate": "node scripts/design-tokens.mjs", + "tokens:check": "node scripts/design-tokens.mjs --check", + "prebuild": "npm run tokens:check" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.2", diff --git a/web/scripts/design-tokens.mjs b/web/scripts/design-tokens.mjs new file mode 100644 index 0000000..4899c60 --- /dev/null +++ b/web/scripts/design-tokens.mjs @@ -0,0 +1,136 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import assert from 'node:assert/strict'; + +// The snapshot, not this compatibility adapter, owns palette values. +const revision = '910456901dda74da4d5b0320cd03d36ad18650b0'; +const root = new URL('../', import.meta.url); +const source = await readFile(new URL('design-system/tokens.json', root), 'utf8'); +const tokens = JSON.parse(source).tokens; +const digest = createHash('sha256').update(source).digest('hex'); +assert.equal( + digest, + '75e62b372f23083c2f35400ce434d40bf1be098375cea85b97352dcfedbf5766', + 'Pinned upstream snapshot changed: review a new revision and its hash', +); +const value = (group, name, mode) => { + const v = tokens[group][name].$value; + return typeof v === 'string' ? v : v[mode]; +}; +const color = (name, mode) => { + const s = value('color', name, mode); + if (s.startsWith('#')) + return [ + ...s + .slice(1) + .match(/../g) + .map((v) => parseInt(v, 16)), + 1, + ]; + const c = s.match(/[\d.]+/g).map(Number); + return c.length === 3 ? [...c, 1] : c; +}; +const mix = (a, b, weight) => + a + .slice(0, 3) + .map((n, i) => Math.round(n * weight + b[i] * (1 - weight))) + .concat(1); +const luminance = (c) => + c + .slice(0, 3) + .map((v) => v / 255) + .map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)) + .reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0); +const contrast = (fg, bg) => { + const a = luminance(mix(fg, bg, fg[3] ?? 1)), + b = luminance(bg); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +}; +let css = `/* GENERATED by scripts/design-tokens.mjs; DO NOT EDIT.\n * Source: bytefolk/design-system@${revision}/tokens/design-tokens.json\n * SHA-256: ${digest}; Apache-2.0 (web/design-system/LICENSE).\n */\n`; +const checks = []; +for (const mode of ['dark', 'light']) { + const surfaces = ['canvas', 'navigation', 'surface', 'surface-inset', 'surface-raised'].map((n) => + color(n, mode), + ); + const target = mode === 'dark' ? [255, 255, 255] : [0, 0, 0]; + // Preserve the upstream hue while making actual small text readable on every + // surface. Upstream base hues are sometimes intended only for borders/icons. + const readable = (base, tinted = false) => { + for (let step = 0; step <= 100; step++) { + const c = + base[3] < 1 + ? [...base.slice(0, 3), base[3] + ((1 - base[3]) * step) / 100] + : mix(base, target, 1 - step / 100); + if ( + surfaces.every( + (bg) => contrast(c, bg) >= 4.5 && (!tinted || contrast(c, mix(c, bg, 0.3)) >= 4.5), + ) + ) + return c; + } + throw new Error('No readable foreground found'); + }; + const vars = { + bg: color('canvas', mode), + 'bg-subtle': color('navigation', mode), + 'bg-panel': color('surface', mode), + 'bg-inset': color('surface-inset', mode), + fg: color('foreground', mode), + 'fg-muted': readable(color('foreground-muted', mode)), + 'fg-subtle': readable(color('foreground-muted', mode)), + border: color('border', mode), + 'border-strong': color('border-strong', mode), + accent: readable(color('primary', mode), true), + 'accent-hover': readable(color('primary-hover', mode), true), + 'accent-muted': color('primary-soft', mode), + 'accent-solid': mix(color('primary', mode), [0, 0, 0], 0.8), + 'accent-solid-hover': mix(color('primary-hover', mode), [0, 0, 0], 0.7), + 'accent-foreground': color('primary-foreground', mode), + ai: readable(color('ai', mode), true), + success: readable(color('success-strong', mode), true), + warn: readable(color('warning-strong', mode), true), + danger: readable(color('danger-strong', mode), true), + }; + for (const name of [ + 'fg', + 'fg-muted', + 'fg-subtle', + 'accent', + 'accent-hover', + 'ai', + 'success', + 'warn', + 'danger', + ]) { + for (const bg of surfaces) { + const ratio = contrast(vars[name], bg); + assert.ok(ratio >= 4.5, `${mode} ${name}: ${ratio}`); + checks.push(ratio); + } + } + // Badges, selected navigation and danger-button hover compose tinted surfaces. + for (const name of ['accent', 'accent-hover', 'ai', 'success', 'warn', 'danger']) { + for (const bg of surfaces) + for (const alpha of [0.05, 0.1, 0.2, 0.3]) { + const ratio = contrast(vars[name], mix(vars[name], bg, alpha)); + assert.ok(ratio >= 4.5, `${mode} ${name} tint ${alpha}: ${ratio}`); + checks.push(ratio); + } + } + for (const name of ['accent-solid', 'accent-solid-hover']) { + const ratio = contrast(vars['accent-foreground'], vars[name]); + assert.ok(ratio >= 4.5, `${mode} ${name}: ${ratio}`); + checks.push(ratio); + } + css += `${mode === 'dark' ? ':root, .dark' : '.light'} {\n`; + for (const [name, c] of Object.entries(vars)) + css += ` --${name}: ${c.slice(0, 3).join(' ')};\n --${name}-opacity: ${c[3]};\n`; + css += ` --shadow-soft: ${value('boxShadow', 'shadow-sm', mode)};\n --font-sans: ${value('fontFamily', 'font-sans', mode)};\n --font-mono: ${value('fontFamily', 'font-mono', mode)};\n color-scheme: ${mode};\n}\n`; +} +const output = new URL('src/styles/design-tokens.generated.css', root); +if (process.argv.includes('--check')) + assert.equal(await readFile(output, 'utf8'), css, 'Regenerate shared design tokens'); +else await writeFile(output, css); +console.log( + `Shared token bridge: ${checks.length} contrast pairs >= ${Math.min(...checks).toFixed(2)}:1; ${process.argv.includes('--check') ? 'current' : 'generated'}`, +); diff --git a/web/src/components/explorer/ContextMenu.tsx b/web/src/components/explorer/ContextMenu.tsx index d12b0d3..24722d7 100644 --- a/web/src/components/explorer/ContextMenu.tsx +++ b/web/src/components/explorer/ContextMenu.tsx @@ -102,7 +102,7 @@ function ContextMenuView({ state, onClose }: { state: ContextMenuState; onClose: requestAnimationFrame(() => item.onSelect()); }} className={cn( - 'flex w-full items-center gap-2 px-2.5 py-1.5 text-sm rounded-sm mx-1 my-0.5', + 'grid w-[calc(100%-0.5rem)] grid-cols-[minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-1.5 text-left text-sm rounded-sm mx-1 my-0.5', 'transition-colors', item.disabled ? 'text-fg-subtle cursor-not-allowed' @@ -111,9 +111,9 @@ function ContextMenuView({ state, onClose }: { state: ContextMenuState; onClose: : 'text-fg-muted hover:bg-bg-inset hover:text-fg', )} > - {item.label} + {item.label} {item.shortcut && ( - {item.shortcut} + {item.shortcut} )} diff --git a/web/src/components/explorer/FileGrid.tsx b/web/src/components/explorer/FileGrid.tsx index 458aa1c..d465cf0 100644 --- a/web/src/components/explorer/FileGrid.tsx +++ b/web/src/components/explorer/FileGrid.tsx @@ -70,9 +70,10 @@ export function FileGrid(props: FileGridProps) { return (
{pendingNewFolder && ( -
+
(
@@ -206,7 +207,7 @@ function FolderCard({ onDragLeave={() => setDropHover(false)} onDrop={onDrop} className={cn( - 'flex flex-col items-center text-center gap-2 p-2 rounded-lg cursor-default select-none', + 'flex flex-col items-center text-left gap-2 p-2 rounded-lg cursor-default select-none', 'border transition-colors', selected ? 'border-accent/60 bg-accent/10' @@ -218,6 +219,7 @@ function FolderCard({ {renaming ? ( )} -
{tt('drive.itemsN', { n: folder.fileCount })}
+
{tt('drive.itemsN', { n: folder.fileCount })}
); } @@ -265,7 +267,7 @@ function FileCard({ onDoubleClick={onDoubleClick} onContextMenu={onContextMenu} className={cn( - 'flex flex-col items-center text-center gap-2 p-2 rounded-lg cursor-default select-none', + 'flex flex-col items-center text-left gap-2 p-2 rounded-lg cursor-default select-none', 'border transition-colors', selected ? 'border-accent/60 bg-accent/10' @@ -286,7 +288,7 @@ function FileCard({ {file.index_status !== 'done' && }
{renaming ? ( - + ) : (
{file.name} @@ -307,10 +309,12 @@ function KindIcon({ kind }: { kind: FileKind }) { function StatusOverlay({ status }: { status: IndexStatus }) { const text = tt(`status.${status}`); - const tone = status === 'failed' ? 'bg-danger/80' : 'bg-bg/70'; + const tone = status === 'failed' + ? 'border border-danger/30 bg-bg-panel text-danger' + : 'bg-bg/70 text-fg'; return (
{text}
diff --git a/web/src/components/explorer/FileList.tsx b/web/src/components/explorer/FileList.tsx index 71e1396..5aaf6a7 100644 --- a/web/src/components/explorer/FileList.tsx +++ b/web/src/components/explorer/FileList.tsx @@ -33,10 +33,10 @@ export interface FileListProps { export function FileList(props: FileListProps) { const { t } = useT(); return ( -
+
{t('drive.colName')}
-
{t('drive.colSize')}
+
{t('drive.colSize')}
{t('drive.colModified')}
{t('drive.colType')}
@@ -215,7 +215,7 @@ function FolderRow({
)}
-
{tt('drive.itemsN', { n: folder.fileCount })}
+
{tt('drive.itemsN', { n: folder.fileCount })}
—
{tt('drive.folder')}
@@ -271,7 +271,7 @@ function FileRow({
)}
-
{formatBytes(file.size)}
+
{formatBytes(file.size)}
{formatRelative(file.updated_at)}
{tt(`kind.${file.kind === 'pdf' ? 'doc' : file.kind}`)}
@@ -285,4 +285,3 @@ function KindIconSmall({ kind }: { kind: FileKind }) { if (kind === 'pdf' || kind === 'doc' || kind === 'text') return ; return ; } - diff --git a/web/src/components/layout/TopBar.tsx b/web/src/components/layout/TopBar.tsx index 97bd25b..066d336 100644 --- a/web/src/components/layout/TopBar.tsx +++ b/web/src/components/layout/TopBar.tsx @@ -186,7 +186,7 @@ export function TopBar({ children }: { children?: React.ReactNode }) { @@ -166,7 +166,7 @@ export function CreateRelationDialog({ setReason(event.target.value as MemoryForgetReason)} - className="h-9 rounded-md border border-border bg-bg-inset px-3 text-sm text-fg outline-none focus:border-accent/60" + className="h-9 rounded-md border border-border bg-bg-inset px-3 text-left [text-align-last:left] text-sm text-fg outline-none focus:border-accent/60" > {REASONS.map((candidate) => (