From d321f98e8ad7b5d7a8dfca946d5b2a24c51add94 Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Thu, 24 Sep 2026 11:39:11 -0700 Subject: [PATCH 1/2] Rebuild devcontainer on prebuilt MCR node base Switch the devcontainer image from a hand-rolled debian:bookworm-slim to mcr.microsoft.com/devcontainers/javascript-node:22-bookworm so node/npm ship in the image layers instead of being fetched from the public npm registry during the build. - Drop the node feature (base provides it) and the playwright feature (which pulled the node feature back in via dependsOn). Chromium for PDF export now comes from Debian apt and is auto-detected on PATH by chromedp. - Keep the go feature (installs from go.dev) and sshd. - Use the base image's native non-root 'node' user; update the home mount target and postCreate accordingly. - Pass NPM_CONFIG_REGISTRY through from the host via remoteEnv so machines behind a corporate npm proxy can still install webui deps at postAttach. It references npm's standard variable, not a hardcoded URL, so it is empty and harmless on public/CI machines. --- .devcontainer/Dockerfile | 38 ++++++++++++++++----------------- .devcontainer/devcontainer.json | 29 +++++++++++-------------- 2 files changed, 31 insertions(+), 36 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e44c9d4..9d51eba 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,28 +1,28 @@ -FROM debian:bookworm-slim +# Prebuilt VS Code devcontainer base with Node.js, npm, git, a non-root +# "node" user, and passwordless sudo already baked in. Using this image +# instead of layering the `node` devcontainer feature means the build +# never fetches node/npm/pnpm/yarn from the public npm registry — the +# toolchain ships in the image layers (pulled from MCR). Go is added via +# the `go` feature (see devcontainer.json); it installs from go.dev, not npm. +FROM mcr.microsoft.com/devcontainers/javascript-node:22-bookworm ARG DEBIAN_FRONTEND=noninteractive +# System packages that don't come from npm: +# - git-lfs: wiki sync routes binary assets through LFS on providers +# that support it. +# - chromium (+ fonts): headless PDF export drives Chromium via chromedp, +# which finds it on PATH as `chromium`. Pulled from the Debian repos, +# so the build needs no npm registry access at all. Fonts keep PDF and +# Mermaid rendering legible. +# - netcat-traditional: used by dev tooling / health probes. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - wget \ - pkg-config \ - libssl-dev \ - ca-certificates \ - git \ git-lfs \ + chromium \ + fonts-liberation \ + fonts-noto-core \ netcat-traditional \ - sudo \ - && git lfs install \ + && git lfs install --system \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* - -# Create non-root user -ARG USERNAME=vscode -ARG USER_UID=1000 -ARG USER_GID=$USER_UID -RUN groupadd --force --gid $USER_GID $USERNAME \ - && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /bin/bash || true \ - && echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/$USERNAME \ - && chmod 0440 /etc/sudoers.d/$USERNAME diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2e760c7..ff2b5b8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,27 +3,14 @@ "build": { "dockerfile": "Dockerfile" }, - "remoteUser": "vscode", + "remoteUser": "node", "features": { "ghcr.io/devcontainers/features/go:1": { "version": "latest", "golangciLintVersion": "1.64.8" }, - "ghcr.io/devcontainers/features/node:1": { - "version": "lts" - }, "ghcr.io/devcontainers/features/sshd:1": { "version": "latest" - }, - // Playwright + browsers + Linux runtime libs in one shot. The - // feature's install.sh runs `npx playwright install --with-deps` - // as the remote user, so the browser binaries land in - // /home/vscode/.cache/ms-playwright/ and the apt-side runtime - // libs (libnss3, libgbm1, etc.) are pulled in too. Reproducible - // and dramatically simpler than maintaining the dep list in our - // Dockerfile. - "ghcr.io/schlich/devcontainer-features/playwright:0": { - "browsers": "chromium" } }, // Runs on the HOST before the container is created/started. Picks a @@ -34,7 +21,15 @@ // [[preferences/devcontainer-ports]] for the full rationale and the // consumer recipes (launch.json, tasks.json, host scripts). "initializeCommand": ".devcontainer/initializeCommand.sh", - "postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && { CHROME=$(find ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | head -1) && [ -n \"$CHROME\" ] && sudo ln -sf \"$CHROME\" /usr/local/bin/chromium || true; }", + // NPM_CONFIG_REGISTRY is passed through from the host environment so a + // machine behind a corporate proxy (whose npm registry differs from the + // public one) can still `npm install` at postAttach. It references npm's + // own standard variable — never a hardcoded URL — so it's empty and + // harmless on a public/CI machine, where npm uses its default registry. + "remoteEnv": { + "NPM_CONFIG_REGISTRY": "${localEnv:NPM_CONFIG_REGISTRY}" + }, + "postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && chromium --version", "postAttachCommand": "npm install --prefix webui", "customizations": { "vscode": { @@ -77,11 +72,11 @@ } }, "mounts": [ - // Bind-mount the workspace's .mind-map/ as the vscode user's home + // Bind-mount the workspace's .mind-map/ as the node user's home // .mind-map/ so the committed config.json (with the pull-only sync // mapping to the GitHub wiki) is what mind-map serve reads. The // wiki dir itself (.mind-map/wiki/) is gitignored and populated by // sync on first launch. - "source=${localWorkspaceFolder}/.mind-map,target=/home/vscode/.mind-map,type=bind,consistency=cached" + "source=${localWorkspaceFolder}/.mind-map,target=/home/node/.mind-map,type=bind,consistency=cached" ] } From 4d75045d4ba1d18cd76ef068c6894b010a969e5b Mon Sep 17 00:00:00 2001 From: Ani Balasubramaniam Date: Thu, 24 Sep 2026 11:39:21 -0700 Subject: [PATCH 2/2] Add PAT authentication for git wiki sync Let users provide a personal access token to authenticate HTTPS git sync when the machine's own git credentials (gh, keychain, SSH) aren't usable. - config: Token on SyncConfig (global default) and SyncMapping (per-remote override); TokenForRemote resolution (mapping -> default -> none) and AddMappingFull that preserves a stored token when none is supplied. - sync: inject the token via a GIT_ASKPASS helper on fetch/push only, so it never lands in .git/config, the remote URL, or a process argument. Reset the inherited credential-helper chain so a broken helper can't shadow it, and scrub the token from surfaced errors and logs. - mcp: optional token on register_sync via a new SyncRegistrarWithToken interface, with graceful fallback; updated tool description. - httpapi: redact tokens in the settings API (never echo the raw value); re-saving the mask preserves the stored token, empty clears, new replaces. - webui: password-style token field under Default Remote, plus a live Sync Status panel that surfaces per-remote last-sync/errors/conflicts from the previously-unused /api/sync/status endpoint. Tokens fill the gap for 2FA accounts, where a PAT is the required HTTPS mechanism. Falls back to existing git credentials when no token is set. --- internal/config/config.go | 68 +++++++++++++++++++- internal/config/config_test.go | 55 +++++++++++++++- internal/httpapi/server.go | 47 ++++++++++++++ internal/httpapi/server_test.go | 87 +++++++++++++++++++++++++ internal/mcp/server.go | 33 +++++++++- internal/sync/auth_test.go | 84 +++++++++++++++++++++++++ internal/sync/sync.go | 108 ++++++++++++++++++++++++++++++-- webui/src/App.tsx | 73 ++++++++++++++++++++- webui/src/styles.css | 46 ++++++++++++++ 9 files changed, 590 insertions(+), 11 deletions(-) create mode 100644 internal/sync/auth_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 81603ac..2556409 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,14 @@ type SyncMapping struct { Prefix string `json:"prefix"` Remote string `json:"remote"` Direction SyncDirection `json:"direction,omitempty"` + // Token is an optional personal access token used to authenticate + // HTTPS git operations for this mapping's remote. When empty, the + // SyncConfig-level Token is used; when that's empty too, sync falls + // back to the machine's existing git credentials (helpers, keychain, + // gh, SSH). Injected via GIT_ASKPASS so it never lands in the shadow + // clone's .git/config or a process argument. Redacted by the settings + // API — it is never echoed back to the browser. + Token string `json:"token,omitempty"` // LFS, when true, configures the synced shadow clone to track // the patterns in LFSPatterns via git-lfs. Useful when binary // assets (uploaded via the image-support tools) would otherwise @@ -69,12 +77,35 @@ func DefaultLFSPatterns() []string { // SyncConfig holds git sync settings. type SyncConfig struct { - Enabled bool `json:"enabled"` - Default string `json:"default"` + Enabled bool `json:"enabled"` + Default string `json:"default"` + // Token is the default personal access token used to authenticate + // HTTPS git operations for any remote that doesn't set its own + // per-mapping Token. Empty means "use the machine's existing git + // credentials" (the historical behavior). Injected via GIT_ASKPASS, + // and redacted by the settings API — never echoed to the browser. + Token string `json:"token,omitempty"` Interval string `json:"interval"` Mappings []SyncMapping `json:"mappings,omitempty"` } +// TokenForRemote returns the access token to use for the given remote. +// A non-empty token on any mapping for that remote wins (first match); +// otherwise the SyncConfig-level default Token is used. Returns "" when +// no token is configured, which the sync engine treats as "fall back to +// the machine's existing git credentials". +func (s *SyncConfig) TokenForRemote(remote string) string { + if remote == "" { + return "" + } + for _, m := range s.Mappings { + if m.Remote == remote && m.Token != "" { + return m.Token + } + } + return s.Token +} + // ParseInterval returns the sync interval as a time.Duration. // Returns the default (30s) if the value is empty or invalid. func (s *SyncConfig) ParseInterval() time.Duration { @@ -156,6 +187,39 @@ func (s *SyncConfig) AddMappingWithLFS(prefix, remote string, direction SyncDire }) } +// AddMappingFull is the superset of AddMappingWithLFS that also manages +// the per-mapping access Token. Remote, direction, and LFS settings are +// replaced on every call (a re-registration). The token is only +// overwritten when a non-empty token is supplied; passing "" preserves +// any existing token so a re-registration that omits the token doesn't +// silently drop stored credentials. +func (s *SyncConfig) AddMappingFull(prefix, remote string, direction SyncDirection, lfs bool, patterns []string, token string) { + direction = direction.Normalize() + if lfs && patterns == nil { + patterns = DefaultLFSPatterns() + } + for i, m := range s.Mappings { + if m.Prefix == prefix { + s.Mappings[i].Remote = remote + s.Mappings[i].Direction = direction + s.Mappings[i].LFS = lfs + s.Mappings[i].LFSPatterns = patterns + if token != "" { + s.Mappings[i].Token = token + } + return + } + } + s.Mappings = append(s.Mappings, SyncMapping{ + Prefix: prefix, + Remote: remote, + Direction: direction, + LFS: lfs, + LFSPatterns: patterns, + Token: token, + }) +} + // Remotes returns all unique remotes (default + mappings). func (s *SyncConfig) Remotes() []string { seen := make(map[string]bool) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bf7acb6..6dc6b6d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -184,7 +184,7 @@ func TestParseCloudRefresh(t *testing.T) { // Floor: anything < 30s clamps to the default to protect a // busy wiki from CPU churn. {"1s", 5 * time.Minute}, - {"", 5 * time.Minute}, // empty → default + {"", 5 * time.Minute}, // empty → default {"junk", 5 * time.Minute}, // invalid → default } for _, tc := range tests { @@ -250,3 +250,56 @@ func TestDigestConfig_BackwardsCompatible(t *testing.T) { t.Errorf("expected default 5m on legacy config, got %v", loaded.Digest.ParseCloudRefresh()) } } + +func TestTokenForRemote(t *testing.T) { + s := &SyncConfig{ + Default: "https://example.com/default.git", + Token: "default-tok", + Mappings: []SyncMapping{ + {Prefix: "a", Remote: "https://example.com/a.git", Token: "a-tok"}, + {Prefix: "b", Remote: "https://example.com/b.git"}, // no token → default + }, + } + cases := []struct { + remote string + want string + }{ + {"https://example.com/a.git", "a-tok"}, // per-mapping override wins + {"https://example.com/b.git", "default-tok"}, // falls back to default + {"https://example.com/default.git", "default-tok"}, + {"https://example.com/unknown.git", "default-tok"}, // unknown → default + {"", ""}, // empty remote → no token + } + for _, c := range cases { + if got := s.TokenForRemote(c.remote); got != c.want { + t.Errorf("TokenForRemote(%q) = %q, want %q", c.remote, got, c.want) + } + } + + // With no default token, an unmapped remote resolves to "". + s.Token = "" + if got := s.TokenForRemote("https://example.com/b.git"); got != "" { + t.Errorf("expected empty token with no default, got %q", got) + } +} + +func TestAddMappingFullPreservesTokenWhenEmpty(t *testing.T) { + s := &SyncConfig{} + s.AddMappingFull("p", "https://example.com/p.git", SyncBidirectional, false, nil, "secret") + if s.Mappings[0].Token != "secret" { + t.Fatalf("token not set on insert: %q", s.Mappings[0].Token) + } + // Re-register without a token: existing token must be preserved. + s.AddMappingFull("p", "https://example.com/p.git", SyncPull, false, nil, "") + if s.Mappings[0].Token != "secret" { + t.Errorf("empty token overwrote stored token: %q", s.Mappings[0].Token) + } + if s.Mappings[0].Direction != SyncPull { + t.Errorf("direction not updated: %q", s.Mappings[0].Direction) + } + // Re-register with a new token: it replaces the old one. + s.AddMappingFull("p", "https://example.com/p.git", SyncPull, false, nil, "rotated") + if s.Mappings[0].Token != "rotated" { + t.Errorf("token not rotated: %q", s.Mappings[0].Token) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 33d6d6e..6db5d0c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -427,12 +427,27 @@ func (s *Server) allLinks(rw http.ResponseWriter, r *http.Request) { writeJSON(rw, links) } +// redactedToken is the placeholder the settings API returns in place of +// a stored sync token. A GET never exposes the real token; a PUT that +// sends this sentinel back means "keep the existing token unchanged". +const redactedToken = "********" + func (s *Server) getSettings(rw http.ResponseWriter, r *http.Request) { current, err := config.Load(s.deps.CfgPath) if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) return } + // Redact tokens so the browser never receives raw credentials. + // config.Load returns a fresh struct, so mutating it here is safe. + if current.Sync.Token != "" { + current.Sync.Token = redactedToken + } + for i := range current.Sync.Mappings { + if current.Sync.Mappings[i].Token != "" { + current.Sync.Mappings[i].Token = redactedToken + } + } writeJSON(rw, current) } @@ -449,6 +464,28 @@ func (s *Server) putSettings(rw http.ResponseWriter, r *http.Request) { return } + // Restore any redacted tokens from the on-disk config so a settings + // save that echoes back the mask preserves the stored credential + // instead of overwriting it with the placeholder. An empty token is + // left empty (an explicit clear); a real new value is taken as-is. + if existing, err := config.Load(s.deps.CfgPath); err == nil { + if incoming.Sync.Token == redactedToken { + incoming.Sync.Token = existing.Sync.Token + } + for i := range incoming.Sync.Mappings { + if incoming.Sync.Mappings[i].Token != redactedToken { + continue + } + incoming.Sync.Mappings[i].Token = "" // default: no prior match + for _, em := range existing.Sync.Mappings { + if em.Prefix == incoming.Sync.Mappings[i].Prefix { + incoming.Sync.Mappings[i].Token = em.Token + break + } + } + } + } + if incoming.Sync.Enabled && incoming.Sync.Default == "" && len(incoming.Sync.Mappings) == 0 { http.Error(rw, "sync requires at least a default remote or one mapping", http.StatusBadRequest) return @@ -468,6 +505,16 @@ func (s *Server) putSettings(rw http.ResponseWriter, r *http.Request) { s.applyConfig(&incoming) slog.Info("settings saved", slog.String("path", s.deps.CfgPath)) + + // Redact tokens in the echoed response, mirroring getSettings. + if incoming.Sync.Token != "" { + incoming.Sync.Token = redactedToken + } + for i := range incoming.Sync.Mappings { + if incoming.Sync.Mappings[i].Token != "" { + incoming.Sync.Mappings[i].Token = redactedToken + } + } writeJSON(rw, &incoming) } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index c08f747..c247eb1 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -405,3 +405,90 @@ func TestGetDigest(t *testing.T) { t.Errorf("recents missing topics/sqlite: %v", d.Recents) } } + +// TestSettingsTokenRedaction verifies the settings API never echoes a raw +// sync token, that re-saving the redacted placeholder preserves the stored +// token, that a new value replaces it, and that an empty value clears it. +func TestSettingsTokenRedaction(t *testing.T) { + dir := t.TempDir() + w, err := wiki.Open(dir) + if err != nil { + t.Fatalf("open wiki: %v", err) + } + t.Cleanup(func() { w.Close() }) + cfgPath := filepath.Join(dir, "config.json") + h := New(Deps{ + Wiki: w, + CfgPath: cfgPath, + Cfg: config.DefaultConfig(), + GetVersion: func() string { return "test" }, + StopCh: make(chan struct{}), + }) + + const secret = "ghp_realtoken_abc123" + + // 1. PUT a real token. + rec := doJSON(t, h, "PUT", "/api/settings", map[string]any{ + "sync": map[string]any{"enabled": true, "default": "https://example.com/w.git", "token": secret}, + }) + if rec.Code != 200 { + t.Fatalf("put token: %d %s", rec.Code, rec.Body.String()) + } + // The PUT response must already be redacted. + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("PUT response leaked the raw token: %s", rec.Body.String()) + } + + // On disk, the real token must be persisted. + onDisk, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("load config: %v", err) + } + if onDisk.Sync.Token != secret { + t.Fatalf("token not persisted to disk: %q", onDisk.Sync.Token) + } + + // 2. GET must return the mask, never the raw token. + rec = doJSON(t, h, "GET", "/api/settings", nil) + if strings.Contains(rec.Body.String(), secret) { + t.Fatalf("GET leaked the raw token: %s", rec.Body.String()) + } + var got config.Config + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.Sync.Token != redactedToken { + t.Fatalf("GET token = %q, want mask %q", got.Sync.Token, redactedToken) + } + + // 3. Re-saving the mask preserves the stored token. + rec = doJSON(t, h, "PUT", "/api/settings", map[string]any{ + "sync": map[string]any{"enabled": true, "default": "https://example.com/w.git", "token": redactedToken}, + }) + if rec.Code != 200 { + t.Fatalf("put mask: %d %s", rec.Code, rec.Body.String()) + } + onDisk, _ = config.Load(cfgPath) + if onDisk.Sync.Token != secret { + t.Fatalf("mask save did not preserve token: %q", onDisk.Sync.Token) + } + + // 4. A new value replaces it. + const rotated = "ghp_rotated_xyz789" + doJSON(t, h, "PUT", "/api/settings", map[string]any{ + "sync": map[string]any{"enabled": true, "default": "https://example.com/w.git", "token": rotated}, + }) + onDisk, _ = config.Load(cfgPath) + if onDisk.Sync.Token != rotated { + t.Fatalf("token not rotated: %q", onDisk.Sync.Token) + } + + // 5. An empty value clears it. + doJSON(t, h, "PUT", "/api/settings", map[string]any{ + "sync": map[string]any{"enabled": true, "default": "https://example.com/w.git", "token": ""}, + }) + onDisk, _ = config.Load(cfgPath) + if onDisk.Sync.Token != "" { + t.Fatalf("empty token did not clear stored token: %q", onDisk.Sync.Token) + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1152e55..53ce7cb 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -39,6 +39,17 @@ type SyncRegistrarWithLFS interface { RegisterMappingWithLFS(prefix, remote string, direction config.SyncDirection, lfs bool, lfsPatterns []string) error } +// SyncRegistrarWithToken is satisfied by sync managers that also accept +// a per-mapping access token. MCP's register_sync tool prefers this when +// available so an agent can supply a PAT for a remote whose credentials +// aren't otherwise resolvable (gh/keychain not configured). Falls back to +// SyncRegistrarWithLFS / SyncRegistrar when unavailable, dropping the token +// with a logged warning. Kept as flat args for the same reason as the LFS +// variant — no cross-package struct dependency. +type SyncRegistrarWithToken interface { + RegisterMappingWithToken(prefix, remote string, direction config.SyncDirection, lfs bool, lfsPatterns []string, token string) error +} + // Server wraps a Wiki and exposes it as MCP tools. type Server struct { wiki *wiki.Wiki @@ -127,7 +138,7 @@ func (s *Server) registerTools() { mcp.AddTool(s.server, &mcp.Tool{ Name: "register_sync", - Description: "Register a wiki path prefix to sync with a git remote. Pages under this prefix will be synced to the given repository's wiki. The remote URL should be a git clone URL (e.g. https://github.com/user/repo.wiki.git). Direction defaults to 'bidirectional' (pull+push); use 'pull' to mirror an upstream repo read-only into the wiki, or 'push' to publish wiki content to a remote without ever pulling from it. Re-registering the same prefix replaces the previous direction. Auth uses the machine's existing git credentials.", + Description: "Register a wiki path prefix to sync with a git remote. Pages under this prefix will be synced to the given repository's wiki. The remote URL should be a git clone URL (e.g. https://github.com/user/repo.wiki.git). Direction defaults to 'bidirectional' (pull+push); use 'pull' to mirror an upstream repo read-only into the wiki, or 'push' to publish wiki content to a remote without ever pulling from it. Re-registering the same prefix replaces the previous direction. Auth normally uses the machine's existing git credentials (git credential helpers, keychain, gh, or SSH). If those aren't available, pass 'token' with a personal access token (PAT) to authenticate HTTPS operations for this remote; it's stored in config and injected securely, never written into the remote URL. Omit 'token' on re-registration to keep a previously stored token.", }, s.registerSync) mcp.AddTool(s.server, &mcp.Tool{ @@ -194,6 +205,10 @@ type registerSyncInput struct { // LFSPatterns, when set, overrides the default LFS .gitattributes // patterns (the browser-renderable image set). LFSPatterns []string `json:"lfs_patterns,omitempty" jsonschema:"optional .gitattributes patterns to route through LFS. If LFS=true and this is empty, the default image-format set is used."` + // Token is an optional PAT for authenticating HTTPS git operations + // on this remote when the machine's own git credentials aren't + // usable. Omit to keep any previously stored token for the prefix. + Token string `json:"token,omitempty" jsonschema:"optional personal access token (PAT) to authenticate HTTPS git sync for this remote. Use when gh/keychain/SSH auth isn't available. Stored in config and injected via a credential helper — never written into the remote URL. Omit on re-registration to preserve an existing token."` } type moveInput struct { @@ -408,7 +423,7 @@ func (s *Server) registerSync(_ context.Context, _ *mcp.CallToolRequest, input r direction = config.SyncBidirectional } - if err := s.registerSyncMapping(input.Prefix, input.Remote, direction, input.LFS, input.LFSPatterns); err != nil { + if err := s.registerSyncMapping(input.Prefix, input.Remote, direction, input.LFS, input.LFSPatterns, input.Token); err != nil { slog.Error("tool.register_sync failed", slog.String("prefix", input.Prefix), slog.String("direction", string(direction)), @@ -423,6 +438,7 @@ func (s *Server) registerSync(_ context.Context, _ *mcp.CallToolRequest, input r slog.String("remote", input.Remote), slog.String("direction", string(direction)), slog.Bool("lfs", input.LFS), + slog.Bool("token", input.Token != ""), ) msg := fmt.Sprintf("Sync registered: pages under '%s' will sync to %s", input.Prefix, input.Remote) @@ -437,6 +453,9 @@ func (s *Server) registerSync(_ context.Context, _ *mcp.CallToolRequest, input r if input.LFS { msg += "; binary assets routed through git-lfs" } + if input.Token != "" { + msg += "; authenticating with the provided access token" + } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: msg}, @@ -449,7 +468,15 @@ func (s *Server) registerSync(_ context.Context, _ *mcp.CallToolRequest, input r // back-compat variant (which silently drops LFS settings). Logs a // warning when LFS was requested but the registrar can't honor it // so the operator isn't misled about the resulting behavior. -func (s *Server) registerSyncMapping(prefix, remote string, direction config.SyncDirection, lfs bool, patterns []string) error { +func (s *Server) registerSyncMapping(prefix, remote string, direction config.SyncDirection, lfs bool, patterns []string, token string) error { + if rt, ok := s.sync.(SyncRegistrarWithToken); ok { + return rt.RegisterMappingWithToken(prefix, remote, direction, lfs, patterns, token) + } + if token != "" { + slog.Warn("register_sync token supplied but registrar doesn't support it; ignoring token", + slog.String("prefix", prefix), + slog.String("remote", remote)) + } if rw, ok := s.sync.(SyncRegistrarWithLFS); ok { return rw.RegisterMappingWithLFS(prefix, remote, direction, lfs, patterns) } diff --git a/internal/sync/auth_test.go b/internal/sync/auth_test.go new file mode 100644 index 0000000..8511d5b --- /dev/null +++ b/internal/sync/auth_test.go @@ -0,0 +1,84 @@ +package sync + +import ( + "os" + "os/exec" + "strings" + "testing" +) + +func TestScrubToken(t *testing.T) { + tok := "ghp_supersecret" + in := "fatal: could not read Password for 'https://x-access-token@github.com': ghp_supersecret" + got := scrubToken(in, tok) + if strings.Contains(got, tok) { + t.Fatalf("token leaked after scrub: %q", got) + } + if !strings.Contains(got, "***") { + t.Fatalf("expected redaction marker, got %q", got) + } + // Empty token is a no-op. + if scrubToken(in, "") != in { + t.Fatalf("empty token should be a no-op") + } +} + +// TestNewAskpassHelper verifies the generated helper answers git's +// username/password prompts from the environment and that the token is +// never written into the script file on disk. +func TestNewAskpassHelper(t *testing.T) { + tok := "ghp_tokenvalue123" + cleanup, env, err := newAskpassHelper(tok) + if err != nil { + t.Fatalf("newAskpassHelper: %v", err) + } + defer cleanup() + + var script, tokenEnv string + for _, e := range env { + if strings.HasPrefix(e, "GIT_ASKPASS=") { + script = strings.TrimPrefix(e, "GIT_ASKPASS=") + } + if strings.HasPrefix(e, "MIND_MAP_SYNC_TOKEN=") { + tokenEnv = strings.TrimPrefix(e, "MIND_MAP_SYNC_TOKEN=") + } + } + if script == "" { + t.Fatal("GIT_ASKPASS not set in env") + } + if tokenEnv != tok { + t.Fatalf("token env = %q, want %q", tokenEnv, tok) + } + + // The script file must not contain the token itself. + body, err := os.ReadFile(script) + if err != nil { + t.Fatalf("read script: %v", err) + } + if strings.Contains(string(body), tok) { + t.Fatal("token was written into the askpass script file") + } + + // Username prompt → x-access-token; anything else → the token (from env). + runPrompt := func(prompt string) string { + cmd := exec.Command(script, prompt) + cmd.Env = append(os.Environ(), "MIND_MAP_SYNC_TOKEN="+tok) + out, err := cmd.Output() + if err != nil { + t.Fatalf("run askpass %q: %v", prompt, err) + } + return string(out) + } + if got := runPrompt("Username for 'https://github.com': "); got != "x-access-token" { + t.Fatalf("username answer = %q, want x-access-token", got) + } + if got := runPrompt("Password for 'https://x-access-token@github.com': "); got != tok { + t.Fatalf("password answer = %q, want the token", got) + } + + // After cleanup the script is gone. + cleanup() + if _, err := os.Stat(script); !os.IsNotExist(err) { + t.Fatalf("askpass script not cleaned up: %v", err) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 81c41e0..49c54d5 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -69,6 +69,11 @@ type syncTarget struct { // clone and writes .gitattributes routing lfsPatterns through it. lfs bool lfsPatterns []string + // token is the resolved access token for this remote (per-mapping + // override or the sync-level default). Empty means "use the + // machine's existing git credentials". Fed to git via GIT_ASKPASS + // on network operations so it never touches disk or a process arg. + token string mu sync.Mutex lastSync time.Time @@ -181,6 +186,10 @@ type MappingOptions struct { // (non-nil) slice is "track nothing" — usable only as a stub // for later configuration. LFSPatterns []string + // Token is an optional access token for this mapping's remote. + // Empty preserves any token already stored for the prefix, so a + // re-registration that omits the token doesn't drop credentials. + Token string } // RegisterMappingWithOptions is the full form of RegisterMapping that @@ -191,7 +200,7 @@ func (m *Manager) RegisterMappingWithOptions(prefix, remote string, opts Mapping m.mu.Lock() defer m.mu.Unlock() - m.cfg.Sync.AddMappingWithLFS(prefix, remote, opts.Direction, opts.LFS, opts.LFSPatterns) + m.cfg.Sync.AddMappingFull(prefix, remote, opts.Direction, opts.LFS, opts.LFSPatterns, opts.Token) if err := config.Save(m.cfgPath, m.cfg); err != nil { return fmt.Errorf("save config: %w", err) } @@ -219,6 +228,20 @@ func (m *Manager) RegisterMappingWithLFS(prefix, remote string, direction config }) } +// RegisterMappingWithToken is the token-aware variant that satisfies the +// mcp package's SyncRegistrarWithToken interface. Like RegisterMappingWithLFS +// it keeps a flat argument shape so the mcp package needs no cross-package +// struct. An empty token preserves any credential already stored for the +// prefix. +func (m *Manager) RegisterMappingWithToken(prefix, remote string, direction config.SyncDirection, lfs bool, lfsPatterns []string, token string) error { + return m.RegisterMappingWithOptions(prefix, remote, MappingOptions{ + Direction: direction, + LFS: lfs, + LFSPatterns: lfsPatterns, + Token: token, + }) +} + // HasMapping returns true if the given page path has a sync mapping // (either explicit or default). func (m *Manager) HasMapping(pagePath string) bool { @@ -309,11 +332,13 @@ func (m *Manager) rebuildTargetsLocked() { // Create or update targets for remote, ri := range remotes { + token := m.cfg.Sync.TokenForRemote(remote) if t, exists := m.targets[remote]; exists { t.prefixes = ri.prefixes t.direction = ri.direction t.lfs = ri.lfs t.lfsPatterns = ri.lfsPatterns + t.token = token } else { dirName := sanitizeDirName(remote) m.targets[remote] = &syncTarget{ @@ -323,6 +348,7 @@ func (m *Manager) rebuildTargetsLocked() { direction: ri.direction, lfs: ri.lfs, lfsPatterns: ri.lfsPatterns, + token: token, } } } @@ -444,7 +470,7 @@ func (m *Manager) syncTarget(ctx context.Context, t *syncTarget) { // (from phase 1) with new remote work. Pull-only also needs the merge // to advance HEAD; push-only needs it as a fast-forward base so the // later push isn't rejected. - if err := gitCmd(ctx, t.cloneDir, "fetch", "origin"); err != nil { + if err := gitCmdAuth(ctx, t.cloneDir, t.token, "fetch", "origin"); err != nil { t.setError(fmt.Sprintf("fetch: %v", err)) return } @@ -478,7 +504,7 @@ func (m *Manager) syncTarget(ctx context.Context, t *syncTarget) { // Only push if we have any commits at all (a fresh clone with no // initial pull and no local content will have none). if err := gitCmd(ctx, t.cloneDir, "rev-parse", "HEAD"); err == nil { - if err := gitCmd(ctx, t.cloneDir, "push", "-u", "origin", "main"); err != nil { + if err := gitCmdAuth(ctx, t.cloneDir, t.token, "push", "-u", "origin", "main"); err != nil { t.setError(fmt.Sprintf("push: %v", err)) return } @@ -687,6 +713,7 @@ func syncableRel(rel string) bool { // --- helpers --- func (t *syncTarget) setError(msg string) { + msg = scrubToken(msg, t.token) slog.Warn("sync error", slog.String("remote", t.remote), slog.String("error", msg)) t.mu.Lock() t.lastError = msg @@ -694,16 +721,89 @@ func (t *syncTarget) setError(msg string) { } func gitCmd(ctx context.Context, dir string, args ...string) error { + return gitCmdAuth(ctx, dir, "", args...) +} + +// gitCmdAuth runs a git command, optionally authenticating with a +// personal access token over HTTPS. When token is non-empty it: +// - resets the inherited credential-helper chain (-c credential.helper=) +// so a broken helper (gh/keychain) can't shadow our token; and +// - installs a GIT_ASKPASS helper that feeds git the username +// "x-access-token" and the token as the password. +// +// The token is passed to the helper via an environment variable, so it +// never lands in the shadow clone's .git/config, the remote URL, or the +// process argument list (where `ps` could see it). Any token that does +// leak into git's output is scrubbed from the returned error. +func gitCmdAuth(ctx context.Context, dir, token string, args ...string) error { + var env []string + if token != "" { + cleanup, askEnv, err := newAskpassHelper(token) + if err != nil { + return fmt.Errorf("prepare git auth: %w", err) + } + defer cleanup() + env = askEnv + // Neutralize inherited credential helpers so our askpass wins. + args = append([]string{"-c", "credential.helper="}, args...) + } + cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = dir cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + cmd.Env = append(cmd.Env, env...) out, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), strings.TrimSpace(string(out)), err) + msg := scrubToken(strings.TrimSpace(string(out)), token) + return fmt.Errorf("git %s: %s: %w", scrubToken(strings.Join(args, " "), token), msg, err) } return nil } +// newAskpassHelper writes a throwaway GIT_ASKPASS script that answers +// git's username/password prompts from the environment, returning the +// env to run git with and a cleanup func that removes the script. The +// token itself is only ever placed in the environment (MIND_MAP_SYNC_TOKEN), +// never written into the script file. +func newAskpassHelper(token string) (cleanup func(), env []string, err error) { + f, err := os.CreateTemp("", "mind-map-askpass-*.sh") + if err != nil { + return nil, nil, err + } + // $1 is git's prompt, e.g. "Username for 'https://github.com': " or + // "Password for 'https://x-access-token@github.com': ". Answer the + // username with a fixed value and everything else with the token. + script := "#!/bin/sh\ncase \"$1\" in\n*[Uu]sername*) printf '%s' \"x-access-token\" ;;\n*) printf '%s' \"$MIND_MAP_SYNC_TOKEN\" ;;\nesac\n" + if _, err := f.WriteString(script); err != nil { + f.Close() + os.Remove(f.Name()) + return nil, nil, err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return nil, nil, err + } + if err := os.Chmod(f.Name(), 0o700); err != nil { + os.Remove(f.Name()) + return nil, nil, err + } + cleanup = func() { os.Remove(f.Name()) } + env = []string{ + "GIT_ASKPASS=" + f.Name(), + "MIND_MAP_SYNC_TOKEN=" + token, + } + return cleanup, env, nil +} + +// scrubToken redacts a token from a string so it can't leak into error +// messages, logs, or the /api/sync/status payload. No-op for empty tokens. +func scrubToken(s, token string) string { + if token == "" || s == "" { + return s + } + return strings.ReplaceAll(s, token, "***") +} + func checkConflicts(ctx context.Context, dir string) []string { cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", "--diff-filter=U") cmd.Dir = dir diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 6c33ed0..eae5d5d 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -16,8 +16,28 @@ mermaid.initialize({ startOnLoad: false, theme: 'default' }); interface SyncSettings { enabled: boolean; default: string; + // token is write-mostly: the server returns a redacted placeholder + // ("********") when a token is stored and never the real value. + // Sending the placeholder back preserves it; sending "" clears it. + token?: string; interval: string; - mappings?: { prefix: string; remote: string }[]; + mappings?: { prefix: string; remote: string; token?: string }[]; +} + +// SyncRemoteStatus / SyncStatus mirror internal/sync.Status. Surfaced in +// the settings panel so a bad token or a merge conflict is visible +// instead of failing silently in the background. +interface SyncRemoteStatus { + remote: string; + prefix: string; + last_sync?: string; + last_error?: string; + conflicts?: string[]; +} + +interface SyncStatus { + enabled: boolean; + remotes?: SyncRemoteStatus[]; } // DigestSettings mirrors internal/config.DigestConfig. All fields are @@ -58,6 +78,11 @@ async function getConfigPath(): Promise { return data.path; } +async function loadSyncStatus(): Promise { + const res = await fetch('/api/sync/status'); + return res.json(); +} + async function requestRestart(): Promise { await fetch('/api/restart', { method: 'POST' }); } @@ -73,6 +98,7 @@ export function App() { const [configPath, setConfigPath] = useState(''); const [settingsDirty, setSettingsDirty] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); + const [syncStatus, setSyncStatus] = useState(null); // Reindex state (settings panel). reindexResult is null until the // first run completes; reindexError holds the most recent failure. @@ -304,11 +330,22 @@ export function App() { setSettingsDirty(false); setSettingsSaved(false); setCurrent(null); + loadSyncStatus().then(setSyncStatus).catch(() => setSyncStatus(null)); } catch (e) { console.error('Failed to load settings:', e); } }; + // While the settings panel is open, refresh sync status on an + // interval so background pull/push errors and conflicts surface + // without a manual reload. + useEffect(() => { + if (!showSettings) return; + const tick = () => loadSyncStatus().then(setSyncStatus).catch(() => {}); + const id = setInterval(tick, 5000); + return () => clearInterval(id); + }, [showSettings]); + const handleSettingsSave = async () => { if (!settings) return; try { @@ -316,6 +353,7 @@ export function App() { setSettings(saved); setSettingsDirty(false); setSettingsSaved(true); + loadSyncStatus().then(setSyncStatus).catch(() => {}); } catch (e) { console.error('Failed to save settings:', e); } @@ -613,6 +651,18 @@ export function App() { /> +
+ +
Optional personal access token (PAT) for HTTPS git auth when gh/keychain/SSH isn't set up. Stored locally and never displayed; leave blank to keep the machine's existing credentials, or replace to change. Clear the field to remove a saved token.
+ updateSync('token', (e.target as HTMLInputElement).value)} + placeholder="ghp_… (leave blank to keep existing)" + /> +
+
How often to pull and push (e.g. 30s, 1m, 5m)
@@ -632,6 +682,27 @@ export function App() { {settings.sync.mappings.map(m => (
{m.prefix} → {m.remote} + {m.token ? 🔒 : null} +
+ ))} +
+ + )} + + {syncStatus && syncStatus.remotes && syncStatus.remotes.length > 0 && ( +
+ +
Live pull/push state, refreshed every few seconds
+
+ {syncStatus.remotes.map(rs => ( +
+
{rs.remote}
+ {rs.last_error + ?
⚠ {rs.last_error}
+ :
✓ {rs.last_sync ? `Last synced ${new Date(rs.last_sync).toLocaleString()}` : 'Waiting for first sync…'}
} + {rs.conflicts && rs.conflicts.length > 0 && ( +
Conflicts: {rs.conflicts.join(', ')}
+ )}
))}
diff --git a/webui/src/styles.css b/webui/src/styles.css index 2c1cd74..d1a1ceb 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -686,6 +686,52 @@ mark { padding: 2px 6px; } +.settings-mapping-token { + font-size: 12px; +} + +.settings-sync-status { + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-sync-remote { + font-size: 13px; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--code-bg); +} + +.settings-sync-remote-name { + margin-bottom: 4px; +} + +.settings-sync-remote-name code { + font-family: var(--font-mono); + font-size: 12px; + word-break: break-all; +} + +.settings-sync-ok { + color: var(--fg-muted); + font-size: 12px; +} + +.settings-sync-error { + color: var(--danger, #d1242f); + font-size: 12px; + word-break: break-word; +} + +.settings-sync-conflicts { + margin-top: 4px; + color: var(--danger, #d1242f); + font-size: 12px; +} + /* --- Empty state --- */ .empty {