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" ] } 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 {