Skip to content

Commit 86e2149

Browse files
committed
Fix(cli): desktop-managed zn uses the token zn connect saved for the app's server
The desktop launcher runs zn in app mode, and app mode took a server token only from --token, ZENNOTES_REMOTE_TOKEN or the desktop profile. The app keeps its own copy in the OS secret store, which zn cannot read, so a desktop-managed zn, and every zn mcp it serves, got a 401 from the server the app was connected to. Running `zn connect <url>` did not help: e614af4 left its credentials to terminal mode, so that nothing saved in the terminal could redirect a desktop script (Kevin on Discord, 2026-09-25). App mode keeps its order and falls back, last, to the token zn connect saved for the URL the app chose. The lookup is keyed by that URL, so the token can only authenticate the app's server, never pick another one, and terminal workspace names still redirect nothing. Connecting the app and running `zn connect <url> --no-default` once is now enough for an agent, with no token in any MCP config. The test that pinned the old rule now expects the saved token for a bare URL, and a new one covers the fallback for the app's workspace, a --server name and a --vault name, with no leak to other URLs and --token and the environment still first. The 401 hint names that exact command. --no-default matters: a plain `zn connect` also makes the server zn's own default, and an MCP server in terminal mode would then stop following the app. When the rejected token is ZENNOTES_REMOTE_TOKEN, which outranks a saved token so zn connect cannot fix it, the hint says so instead. Under the desktop launcher, `zn connect` no longer claims "zn uses it by default now": there only zn tui does, and the other commands keep following the app. Verified with a real token-protected server and the zn binary over stdio: zn 0.4.0 answers 401 in app mode, and this build lists the notes.
1 parent 35a77be commit 86e2149

8 files changed

Lines changed: 230 additions & 13 deletions

File tree

‎README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ a different one. Standalone installations keep their own default. Use
5151
`--workspace-source app|terminal` or `ZENNOTES_WORKSPACE_SOURCE` to select the source
5252
explicitly; `--vault` and `--server` still select a specific destination. The TUI
5353
uses its own saved selection unless `--workspace-source app` is explicitly passed.
54+
When the desktop app is on a ZenNotes server, desktop-managed commands and `zn mcp`
55+
authenticate with the token `zn connect` saved for that server's URL: the app keeps
56+
its own copy in the OS secret store, which zn cannot read.
5457

5558
If an older desktop build reports **Unknown command** for `zn tui`, check
5659
`type -a zn`. A Homebrew installation can be run directly with
@@ -342,6 +345,20 @@ message naming the old and the new vault until the assistant calls
342345
`vault_info`, which confirms the switch, and the calls after it run in the
343346
new vault. Nothing an assistant planned in one vault lands in the other.
344347

348+
A vault on a ZenNotes server that needs a token gets it the way every
349+
command does: `--token`, then `ZENNOTES_REMOTE_TOKEN`, then the token
350+
`zn connect` saved for the server's URL. The desktop app keeps its copy in
351+
the OS secret store, which zn cannot read, so run this once and the MCP
352+
server authenticates whenever the app is connected to that server, with no
353+
token in any client config:
354+
355+
```bash
356+
zn connect https://notes.example.com --no-default
357+
```
358+
359+
`--no-default` saves the token without making the server zn's own default,
360+
so zn keeps following the app.
361+
345362
## Demo recordings
346363

347364
`docs/demo/` holds the seeded vaults and the keystroke choreography;

‎docs/desktop-integration.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ Desktop-managed commands set `ZENNOTES_WORKSPACE_SOURCE=app`. They use desktop
1010
vault/profile names, credentials and current selection. A terminal workspace with
1111
the same name cannot redirect an existing desktop script or MCP client.
1212

13+
The desktop keeps server tokens in the OS secret store, which zn cannot read. When
14+
neither `--token`, `ZENNOTES_REMOTE_TOKEN` nor the desktop profile supplies one, app
15+
mode uses the token `zn connect` saved for the same server URL. The lookup is keyed
16+
by the URL the desktop selected, so it authenticates that server and never selects
17+
a different one.
18+
1319
`zn tui` defaults to the separately saved terminal workspace. Explicit selectors
1420
and `--workspace-source app|terminal` take precedence. MCP resolves the workspace
1521
again before every tool call and follows a desktop switch once `vault_info`

‎internal/backend/desktop_workspace_test.go‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ func TestDesktopWorkspaceSourceKeepsDesktopTokenPrecedence(t *testing.T) {
7676
{"app default", "", "", "app", "app-token"},
7777
{"app server", "", "home", "app", "app-token"},
7878
{"app vault", "home", "", "app", "app-token"},
79-
{"app URL", "", baseURL, "app", ""},
79+
// No desktop token comes with a bare URL, so the one `zn connect`
80+
// saved for it is the fallback.
81+
{"app URL", "", baseURL, "app", "terminal-token"},
8082
{"terminal default", "", "", "terminal", "terminal-token"},
8183
{"terminal server", "", "home", "terminal", "terminal-token"},
8284
{"terminal URL", "", baseURL, "terminal", "terminal-token"},
@@ -98,3 +100,65 @@ func TestDesktopWorkspaceSourceKeepsDesktopTokenPrecedence(t *testing.T) {
98100
})
99101
}
100102
}
103+
104+
// The app keeps its server tokens in the OS secret store, so a current
105+
// desktop config carries none. App mode then falls back to the token
106+
// `zn connect` saved for the server the app has open, and to nothing else.
107+
func TestDesktopWorkspaceSourceFallsBackToTheZnConnectToken(t *testing.T) {
108+
dir := t.TempDir()
109+
t.Setenv("ZENNOTES_CONFIG_DIR", dir)
110+
t.Setenv("ZENNOTES_VAULT", "")
111+
t.Setenv("ZENNOTES_SERVER", "")
112+
t.Setenv(RemoteTokenEnv, "")
113+
data, _ := json.Marshal(map[string]any{
114+
"workspaceMode": "remote", "remoteWorkspace": map[string]string{"baseUrl": "https://notes.example.com/"},
115+
"remoteWorkspaceProfileId": "p1",
116+
"remoteWorkspaceProfiles": []map[string]string{{"id": "p1", "name": "home", "baseUrl": "https://Notes.Example.com"}},
117+
})
118+
if err := os.WriteFile(filepath.Join(dir, config.AppConfigFile), data, 0600); err != nil {
119+
t.Fatal(err)
120+
}
121+
for _, tc := range []struct{ name, vault, server, want string }{
122+
{"default", "", "", ""},
123+
{"server", "", "home", ""},
124+
{"vault", "home", "", ""},
125+
} {
126+
if got, err := ResolveTargetWithSource(tc.vault, tc.server, "", "app"); err != nil || got.AuthToken != tc.want {
127+
t.Fatalf("%s before zn connect: token %q, error %v", tc.name, got.AuthToken, err)
128+
}
129+
}
130+
if err := config.SaveToken("https://notes.example.com", "zn-token"); err != nil {
131+
t.Fatal(err)
132+
}
133+
if err := config.SaveToken("https://other.example.com", "other-token"); err != nil {
134+
t.Fatal(err)
135+
}
136+
// A terminal default elsewhere must not redirect the app's workspace.
137+
ws := config.LoadWorkspaces()
138+
ws.AddServer("other", "https://other.example.com")
139+
ws.Default = "other"
140+
if err := config.SaveWorkspaces(ws); err != nil {
141+
t.Fatal(err)
142+
}
143+
for _, tc := range []struct{ name, vault, server, wantURL, want string }{
144+
{"default", "", "", "https://notes.example.com", "zn-token"},
145+
{"server", "", "home", "https://Notes.Example.com", "zn-token"},
146+
{"vault", "home", "", "https://Notes.Example.com", "zn-token"},
147+
{"other URL", "", "https://other.example.com", "https://other.example.com", "other-token"},
148+
{"unsaved URL", "", "https://third.example.com", "https://third.example.com", ""},
149+
} {
150+
t.Run(tc.name, func(t *testing.T) {
151+
got, err := ResolveTargetWithSource(tc.vault, tc.server, "", "app")
152+
if err != nil || got.Kind != KindRemote || got.BaseURL != tc.wantURL || got.AuthToken != tc.want {
153+
t.Fatalf("got %s token %q, error %v; want %s token %q", got.Label(), got.AuthToken, err, tc.wantURL, tc.want)
154+
}
155+
t.Setenv(RemoteTokenEnv, "env-token")
156+
if got, _ := ResolveTargetWithSource(tc.vault, tc.server, "", "app"); got.AuthToken != "env-token" {
157+
t.Fatalf("environment token did not win: %q", got.AuthToken)
158+
}
159+
if got, _ := ResolveTargetWithSource(tc.vault, tc.server, "flag-token", "app"); got.AuthToken != "flag-token" {
160+
t.Fatalf("flag token did not win: %q", got.AuthToken)
161+
}
162+
})
163+
}
164+
}

‎internal/backend/target.go‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,26 @@ func ResolveAuthTokenFor(baseURL, flagToken, profileToken string) string {
6262
return strings.TrimSpace(profileToken)
6363
}
6464

65-
// ResolveAuthTokenForSource preserves the desktop CLI's token precedence in
66-
// app mode; terminal mode may additionally use credentials saved by zn connect.
65+
// ResolveAuthTokenForSource is the token for a server the given source
66+
// picked. Both sources take `--token`, then the environment. Terminal mode
67+
// then prefers the token `zn connect` saved for the URL over the desktop
68+
// profile's. App mode keeps the desktop profile's token first and falls back
69+
// to the saved one, because the app keeps its own copy in the OS secret
70+
// store, where zn cannot read it: without the fallback, a desktop-managed zn
71+
// and its `zn mcp` cannot reach the server the app is connected to at all.
72+
// The saved token is looked up by the URL the app chose, so it can only
73+
// authenticate that server, never pick a different one.
6774
func ResolveAuthTokenForSource(baseURL, flagToken, profileToken, source string) string {
68-
if source == "app" {
69-
return ResolveAuthToken(flagToken, profileToken)
75+
if source != "app" {
76+
return ResolveAuthTokenFor(baseURL, flagToken, profileToken)
7077
}
71-
return ResolveAuthTokenFor(baseURL, flagToken, profileToken)
78+
if token := ResolveAuthToken(flagToken, profileToken); token != "" {
79+
return token
80+
}
81+
if baseURL == "" {
82+
return ""
83+
}
84+
return config.LoadToken(baseURL)
7285
}
7386

7487
// Label names a target the way Backend.Label names an open one: the

‎internal/cli/commands_workspace.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ func cmdConnect(ctx context.Context, args Args) error {
122122
return nil
123123
}
124124
emitOK(fmt.Sprintf("Connected to %s (%s)", entry.Name, baseURL))
125+
if source, _ := backend.ResolveWorkspaceSource(args.Str("workspace-source")); source == "app" {
126+
// A desktop-managed zn follows the app whatever zn's default says, so
127+
// "zn uses it by default now" would be wrong here: only zn tui does.
128+
if makeDefault {
129+
emitLine("zn tui opens it by default now. Other zn commands and zn mcp keep following the ZenNotes app, and use this token whenever the app is connected to " + baseURL + ".")
130+
} else {
131+
emitLine("zn commands and zn mcp use this token whenever the ZenNotes app is connected to " + baseURL + "; `--server " + baseURL + "` reaches it directly.")
132+
}
133+
return nil
134+
}
125135
if makeDefault {
126136
emitLine("zn and zn tui use it by default now. `zn use <name>` switches, `zn disconnect " + entry.Name + "` forgets it.")
127137
} else {

‎internal/cli/workspace_test.go‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,3 +101,56 @@ func TestConnectInitUseAndList(t *testing.T) {
101101
t.Fatal("disconnect forgets the server and its token")
102102
}
103103
}
104+
105+
// A desktop-managed zn follows the app, so `zn connect` must not claim zn
106+
// now uses the server by default; what it does is give zn (and zn mcp) the
107+
// token for the server whenever the app has it open.
108+
func TestConnectUnderTheDesktopLauncherAuthenticatesTheAppsServer(t *testing.T) {
109+
dir := t.TempDir()
110+
t.Setenv("ZENNOTES_CONFIG_DIR", dir)
111+
t.Setenv("ZENNOTES_VAULT", "")
112+
t.Setenv("ZENNOTES_SERVER", "")
113+
t.Setenv("ZENNOTES_REMOTE_TOKEN", "")
114+
t.Setenv("ZENNOTES_WORKSPACE_SOURCE", "app")
115+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
116+
if r.Header.Get("Authorization") != "Bearer right" {
117+
w.WriteHeader(http.StatusUnauthorized)
118+
return
119+
}
120+
w.Header().Set("Content-Type", "application/json")
121+
switch r.URL.Path {
122+
case "/api/vault":
123+
_, _ = w.Write([]byte(`{"root":"/srv/notes","name":"notes"}`))
124+
case "/api/notes":
125+
_, _ = w.Write([]byte(`[]`))
126+
default:
127+
_, _ = w.Write([]byte(`{}`))
128+
}
129+
}))
130+
defer server.Close()
131+
app := `{"workspaceMode":"remote","remoteWorkspace":{"baseUrl":"` + server.URL + `"},"remoteWorkspaceProfiles":[{"name":"home","baseUrl":"` + server.URL + `"}]}`
132+
if err := os.WriteFile(filepath.Join(dir, config.AppConfigFile), []byte(app), 0o600); err != nil {
133+
t.Fatal(err)
134+
}
135+
136+
out := captureOutput(t, func() { _ = Main([]string{"list", "--json"}) })
137+
if !strings.Contains(out, "zn:") {
138+
t.Fatalf("without a token the app's server must refuse: %s", out)
139+
}
140+
out = captureOutput(t, func() {
141+
if code := Main([]string{"connect", server.URL, "--token", "right"}); code != 0 {
142+
t.Errorf("connect failed")
143+
}
144+
})
145+
if !strings.Contains(out, "zn tui opens it by default now") || !strings.Contains(out, "keep following the ZenNotes app") || strings.Contains(out, "zn and zn tui use it") {
146+
t.Fatalf("connect output under the desktop launcher: %s", out)
147+
}
148+
out = captureOutput(t, func() { _ = Main([]string{"list", "--json"}) })
149+
if strings.Contains(out, "zn:") {
150+
t.Fatalf("the saved token must reach the app's server: %s", out)
151+
}
152+
out = captureOutput(t, func() { _ = Main([]string{"connect", server.URL, "--token", "right", "--no-default"}) })
153+
if !strings.Contains(out, "zn commands and zn mcp use this token whenever the ZenNotes app is connected to "+server.URL) {
154+
t.Fatalf("--no-default output under the desktop launcher: %s", out)
155+
}
156+
}

‎internal/mcp/run_test.go‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package mcp
33
import (
44
"context"
55
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
68
"os"
79
"path/filepath"
810
"strings"
@@ -122,3 +124,34 @@ func TestRunFollowsAVaultSwitchOnlyAfterVaultInfo(t *testing.T) {
122124
t.Fatalf("the switch note repeats: %s", text)
123125
}
124126
}
127+
128+
func TestRunNamesTheZnConnectCommandOnA401(t *testing.T) {
129+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
130+
w.WriteHeader(http.StatusUnauthorized)
131+
}))
132+
defer server.Close()
133+
cs := runServer(t, func() (backend.Target, error) {
134+
return backend.Target{Kind: backend.KindRemote, BaseURL: server.URL}, nil
135+
})
136+
text, isErr := callTool(t, cs, "list_notes", nil)
137+
if !isErr || !strings.Contains(text, "zn connect "+server.URL+" --no-default") || !strings.Contains(text, "ZENNOTES_REMOTE_TOKEN") {
138+
t.Fatalf("401 hint: %s", text)
139+
}
140+
}
141+
142+
// A stale ZENNOTES_REMOTE_TOKEN outranks any token `zn connect` saves, so
143+
// the hint has to name it rather than send the user to zn connect again.
144+
func TestRunBlamesAStaleEnvironmentTokenOnA401(t *testing.T) {
145+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
146+
w.WriteHeader(http.StatusUnauthorized)
147+
}))
148+
defer server.Close()
149+
t.Setenv(backend.RemoteTokenEnv, "stale")
150+
cs := runServer(t, func() (backend.Target, error) {
151+
return backend.Target{Kind: backend.KindRemote, BaseURL: server.URL, AuthToken: "stale"}, nil
152+
})
153+
text, isErr := callTool(t, cs, "list_notes", nil)
154+
if !isErr || !strings.Contains(text, "The rejected token is ZENNOTES_REMOTE_TOKEN") || !strings.Contains(text, "zn connect "+server.URL+" --no-default") {
155+
t.Fatalf("stale environment token hint: %s", text)
156+
}
157+
}

‎internal/mcp/server.go‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"encoding/json"
1212
"errors"
1313
"fmt"
14+
"os"
1415
"sort"
1516
"strings"
1617

@@ -171,7 +172,7 @@ func tools() []toolDef {
171172
if description.Kind == backend.KindRemote {
172173
notes := "This vault lives on a self-hosted ZenNotes server, the workspace the desktop app currently has open. Every tool works on it through the server API; paths are vault-relative POSIX paths exactly as the server reports them. " + pathNotes
173174
if !description.AuthConfigured {
174-
notes += " No token is configured for this server in this process. If calls fail with 401, set ZENNOTES_REMOTE_TOKEN in the MCP server's environment to the token the server was started with."
175+
notes += " No token is configured for this server in this process. If calls fail with 401, " + tokenHint(description.BaseURL)
175176
}
176177
var serverName, vaultPath, vaultName *string
177178
if description.Name != "" {
@@ -998,16 +999,36 @@ func CallTool(ctx context.Context, name string, a map[string]any, b Backend) (an
998999

9991000
// DescribeToolError phrases a failure for the agent. A server that turns
10001001
// the request away is the one failure an agent cannot fix by retrying: the
1001-
// desktop app keeps its token in the OS secret store, so this process only
1002-
// has one if the environment supplied it.
1003-
func DescribeToolError(err error) string {
1002+
// desktop app keeps its token in the OS secret store, where zn cannot read
1003+
// it, so say how to give this process one. b is the backend the call ran
1004+
// against, nil when none resolved.
1005+
func DescribeToolError(err error, b Backend) string {
10041006
msg := err.Error()
10051007
if status := remote.StatusOf(err); status == 401 || status == 403 {
1006-
return msg + " The MCP server has no valid token for this ZenNotes server. Set ZENNOTES_REMOTE_TOKEN in the MCP server's environment (the desktop app's copy lives in the OS secret store, which zn cannot read), or run zn with --token."
1008+
serverURL := ""
1009+
if r, ok := b.(*backend.Remote); ok {
1010+
serverURL = r.Client().BaseURL
1011+
// The environment outranks a saved token, so a stale one there
1012+
// keeps failing however often `zn connect` runs.
1013+
if fromEnv := strings.TrimSpace(os.Getenv(backend.RemoteTokenEnv)); fromEnv != "" && r.Client().AuthToken == fromEnv {
1014+
return msg + " The rejected token is ZENNOTES_REMOTE_TOKEN from the MCP server's environment, which wins over one saved with `zn connect`: update it there, or remove it and run `zn connect " + serverURL + " --no-default` once in a terminal."
1015+
}
1016+
}
1017+
return msg + " The MCP server has no valid token for this ZenNotes server; the desktop app keeps its copy in the OS secret store, which zn cannot read. To fix it, " + tokenHint(serverURL)
10071018
}
10081019
return msg
10091020
}
10101021

1022+
// tokenHint says how to hand zn a server token. `--no-default` matters: a
1023+
// plain `zn connect` also makes the server zn's own default, and an MCP
1024+
// server in terminal mode would then stop following the app.
1025+
func tokenHint(serverURL string) string {
1026+
if serverURL == "" {
1027+
serverURL = "<server-url>"
1028+
}
1029+
return "run `zn connect " + serverURL + " --no-default` once in a terminal (it saves the token where zn and this MCP server look, and the next call uses it), or set ZENNOTES_REMOTE_TOKEN in the MCP server's environment."
1030+
}
1031+
10111032
// Payload renders a tool result the way the desktop server does: a string
10121033
// as-is, anything else as two-space-indented JSON.
10131034
func Payload(result any) (string, error) {
@@ -1052,14 +1073,14 @@ func Run(ctx context.Context, opts Options) error {
10521073
}
10531074
b, previous, err := vaults.backendFor(def.name == "vault_info")
10541075
if err != nil {
1055-
return errorResult("Error: " + DescribeToolError(err)), nil
1076+
return errorResult("Error: " + DescribeToolError(err, nil)), nil
10561077
}
10571078
if previous != nil {
10581079
ctx = context.WithValue(ctx, previousVaultKey{}, *previous)
10591080
}
10601081
result, err := def.handler(ctx, args(a), b)
10611082
if err != nil {
1062-
return errorResult("Error: " + DescribeToolError(err)), nil
1083+
return errorResult("Error: " + DescribeToolError(err, b)), nil
10631084
}
10641085
payload, err := Payload(result)
10651086
if err != nil {

0 commit comments

Comments
 (0)