Skip to content

Commit 57c5e1a

Browse files
committed
refactor: simplify secret storage to keychain-only
- remove 1Password configuration paths from auth, CLI, MCP guidance, and docs - fence keychain backend probes behind bounded timeouts so status and tool calls cannot hang the stdio transport - add regression coverage for probe timeouts and bump the package version to 0.19.10
1 parent 72b0dd8 commit 57c5e1a

12 files changed

Lines changed: 240 additions & 537 deletions

File tree

.codex-plugin/plugin.json

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"interface": {
2323
"displayName": "Notion Agent Labbook",
2424
"shortDescription": "Connect a project to selected Notion pages and data sources with a local Internal Integration secret.",
25-
"longDescription": "Notion Agent Labbook handles local Notion configuration and project binding for Codex, Claude Code, and other MCP clients. It uses a Notion Internal Integration secret directly, can open the Notion integrations dashboard for setup, detects whether system keychain and 1Password are available, and stores only non-secret project metadata in `.labbook/`.",
25+
"longDescription": "Notion Agent Labbook handles local Notion configuration and project binding for Codex, Claude Code, and other MCP clients. It uses a Notion Internal Integration secret directly, can open the Notion integrations dashboard for setup, detects whether the local system keychain is available, and stores only non-secret project metadata in `.labbook/`.",
2626
"developerName": "Binbin Shen",
2727
"category": "Productivity",
2828
"capabilities": [
@@ -35,9 +35,8 @@
3535
"termsOfServiceURL": "https://www.notion.so/policies/terms-of-use",
3636
"defaultPrompt": [
3737
"Check the current project's Notion status and bound resources before assuming Notion is available.",
38-
"If the project is not authenticated, use notion_prepare_internal_integration first so the user can get the Internal Integration Secret and inspect storage_options, storage_default, and storage_choice_required.",
39-
"If storage_choice_required is true, ask whether the user wants keychain or 1password before calling notion_configure_internal_integration.",
40-
"Use notion_configure_internal_integration to validate and store the secret with the chosen storage value.",
38+
"If the project is not authenticated, use notion_prepare_internal_integration first so the user can get the Internal Integration Secret and inspect storage_options and storage_default.",
39+
"Use notion_configure_internal_integration to validate and store the secret, or recommend `agent-labbook configure-secret --storage keychain` for a local hidden prompt.",
4140
"Before choosing a binding UX, inspect notion_status.binding_recommendation and notion_status.binding_options.",
4241
"Ask whether the user can paste exact Notion links. If yes, prefer notion_bind_resource_urls. If not and the environment is desktop-capable, prefer notion_open_binding_browser. Otherwise use notion_search_resources plus notion_discover_children and then notion_bind_resources.",
4342
"Use notion_status or doctor for health checks, and return the official Notion API headers and bound resource IDs only when direct API calls are actually needed."

README.md

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ No OAuth, no hosted broker, no cloud worker. You connect Notion once, store the
1111

1212
## What It Does
1313

14-
- Stores your Notion Internal Integration secret in the system keychain or 1Password
14+
- Stores your Notion Internal Integration secret in the local system keychain
1515
- Lets an MCP client search, discover, and bind specific Notion pages or data sources
1616
- Returns API headers and bound resource IDs for direct Notion API calls
1717
- Provides a browser-based resource chooser for desktop environments
@@ -36,12 +36,6 @@ Recommended on a workstation:
3636
uvx agent-labbook configure-secret --storage keychain
3737
```
3838

39-
Optional 1Password flow:
40-
41-
```bash
42-
uvx agent-labbook configure-secret --storage 1password --op-vault Private
43-
```
44-
4539
CI or temporary override:
4640

4741
```bash
@@ -51,7 +45,6 @@ export NOTION_AGENT_LABBOOK_TOKEN=secret_xxx
5145
Default policy:
5246

5347
- `keychain` is the default local backend
54-
- `1password` is opt-in
5548
- `NOTION_AGENT_LABBOOK_TOKEN` is for CI or temporary overrides
5649

5750
## 3. Install The MCP Server
@@ -126,7 +119,7 @@ Typical flow:
126119
| `notion_open_binding_browser` | Start a local browser-based chooser for selecting Notion roots. | No | No |
127120
| `notion_list_bindings` | List the Notion resources currently bound to this project. | Yes | No |
128121
| `notion_get_api_context` | Return the Internal Integration secret, official Notion API headers, and bound resource IDs for direct API calls. | Yes | No |
129-
| `notion_clear_project_auth` | Remove the saved project-local session and delete the stored keychain or 1Password secret. | No | Yes |
122+
| `notion_clear_project_auth` | Remove the saved project-local session and delete the stored keychain secret. | No | Yes |
130123

131124
### Resources (3)
132125

@@ -155,7 +148,7 @@ Typical flow:
155148
| Command | Description |
156149
|---------|-------------|
157150
| `agent-labbook mcp` | Run the MCP stdio server. |
158-
| `agent-labbook configure-secret` | Prompt for the Notion Internal Integration secret and store it locally. Supports `--storage`, `--op-vault`, `--op-item-title`. |
151+
| `agent-labbook configure-secret` | Prompt for the Notion Internal Integration secret and store it locally. Supports `--storage`. |
159152
| `agent-labbook doctor` | Inspect local Notion Agent Labbook state and print diagnostics as JSON. |
160153
| `agent-labbook print-mcp-config` | Print a reusable `uvx`-based MCP server config snippet. |
161154

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "agent-labbook"
7-
version = "0.19.9"
7+
version = "0.19.10"
88
description = "Notion Internal Integration binding for Codex, Claude Code, and other MCP clients."
99
readme = "README.md"
1010
requires-python = ">=3.10"

skills/labbook/SKILL.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ It is not a general Notion wrapper or task-management layer.
2424

2525
1. Call `notion_status` or read `labbook://agent-labbook/project/status`.
2626
2. If the project is not authenticated, call `notion_prepare_internal_integration`.
27-
3. Default to `agent-labbook configure-secret --storage keychain` on a workstation. Use `--storage 1password` only when the user explicitly wants 1Password.
27+
3. Use `agent-labbook configure-secret --storage keychain` on a workstation so the secret is captured via a local hidden prompt and stored in the system keychain.
2828
4. Use `notion_configure_internal_integration` only when the caller can safely provide the secret directly.
2929
5. Remind the user to share the target pages or data sources with the integration bot inside Notion.
3030
6. Prefer `notion_bind_resource_urls` for exact links, `notion_open_binding_browser` on desktop, or `notion_search_resources` plus `notion_discover_children` in headless environments.
@@ -45,4 +45,4 @@ It is not a general Notion wrapper or task-management layer.
4545
- Do not assume Notion is connected for the current project until `notion_status` confirms it.
4646
- Do not use `notion_get_api_context` as a health check; prefer `notion_status` and `notion_search_resources`.
4747
- Reuse aliases from `notion_list_bindings` so later sessions stay consistent.
48-
- Project-local state lives under `.labbook/` and should never be committed. The integration secret itself should come from system keychain, 1Password, or the process environment, not `.labbook/session.json`.
48+
- Project-local state lives under `.labbook/` and should never be committed. The integration secret itself should come from the system keychain or the process environment, not `.labbook/session.json`.

src/labbook/auth.py

Lines changed: 59 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import logging
77
import os
8+
import threading
89
from datetime import datetime, timezone
910
from pathlib import Path
1011
from typing import Any
@@ -31,10 +32,6 @@
3132
keychain_delete_token,
3233
keychain_retrieve_token,
3334
keychain_store_token,
34-
op_backend_status,
35-
op_delete_token,
36-
op_retrieve_token,
37-
op_store_token,
3835
)
3936

4037
logger = logging.getLogger("labbook.auth")
@@ -48,7 +45,7 @@
4845
NOTION_INTEGRATION_GUIDE_URL = (
4946
"https://developers.notion.com/guides/get-started/create-a-notion-integration"
5047
)
51-
SUPPORTED_STORAGE_BACKENDS = ("keychain", "1password")
48+
SUPPORTED_STORAGE_BACKENDS = ("keychain",)
5249
DEFAULT_PERSISTENT_STORAGE = "keychain"
5350

5451

@@ -67,15 +64,13 @@ def setup_guide() -> str:
6764
"4. Call `notion_get_api_context` only when you are ready to use the official Notion API.",
6865
"",
6966
"Default workstation path: keychain.",
70-
"Use `--storage 1password` only when you explicitly want 1Password.",
7167
f"Use `{TOKEN_ENV_VAR}` only for CI or temporary overrides.",
7268
"",
7369
"## Security Notes",
7470
"",
7571
"- `NOTION_AGENT_LABBOOK_TOKEN` takes precedence over any locally stored secret for the current process and is intended for CI or temporary overrides.",
7672
"- `agent-labbook configure-secret` uses a local hidden prompt so the secret does not need to be pasted into chat or shell history.",
77-
"- When you choose `keychain`, the secret is stored in the local system credential store through Python `keyring`.",
78-
"- When you choose `1password`, the secret is stored as a 1Password Password item and retrieved later with `op read`.",
73+
"- The secret is stored in the local system credential store through Python `keyring`.",
7974
"- Treat the integration secret like a password. Do not paste it into chat transcripts, logs, or committed files.",
8075
"",
8176
"## Notion Resources",
@@ -115,11 +110,6 @@ def _session_payload(
115110
"bot_owner_type": bot_owner_type,
116111
"keyring_service": kw.get("keyring_service"),
117112
"keyring_account": kw.get("keyring_account"),
118-
"op_item_id": kw.get("op_item_id"),
119-
"op_item_title": kw.get("op_item_title"),
120-
"op_vault": kw.get("op_vault"),
121-
"op_vault_id": kw.get("op_vault_id"),
122-
"op_ref": kw.get("op_ref"),
123113
"configured_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
124114
"notion_version": DEFAULT_NOTION_VERSION,
125115
}
@@ -131,12 +121,51 @@ def _session_payload(
131121

132122

133123
def _available_storage_backends() -> list[dict[str, Any]]:
134-
backends = [keychain_backend_status(), op_backend_status()]
124+
"""Probe keychain availability without blocking the MCP loop.
125+
126+
The probe runs in a daemon thread and is fenced by the configured
127+
probe-timeout budget (``LABBOOK_PROBE_TIMEOUT_SECONDS``) plus a small
128+
headroom, so a locked Secret Service / keychain daemon can never stall
129+
a ``tools/call`` long enough for the client to close the stdio transport.
130+
"""
131+
from .storage import _probe_timeout_default # local import to avoid cycles
132+
133+
timeout = _probe_timeout_default()
134+
join_budget = max(0.5, timeout + 1.0)
135+
136+
result: dict[str, dict[str, Any]] = {}
137+
138+
def _run_keychain() -> None:
139+
try:
140+
result["keychain"] = keychain_backend_status(probe_timeout=timeout)
141+
except Exception as exc: # noqa: BLE001 - defensive: never raise past join
142+
result["keychain"] = {
143+
"backend": "keychain",
144+
"display_name": "System Keychain",
145+
"available": False,
146+
"selected_by_default": False,
147+
"reason": f"Keychain probe crashed: {exc}",
148+
"details": {"keyring_backend": "unknown"},
149+
}
150+
151+
thread = threading.Thread(
152+
target=_run_keychain, name="labbook-probe-keychain", daemon=True
153+
)
154+
thread.start()
155+
thread.join(timeout=join_budget)
156+
157+
keychain_entry = result.get("keychain") or {
158+
"backend": "keychain",
159+
"display_name": "System Keychain",
160+
"available": False,
161+
"selected_by_default": False,
162+
"reason": f"System Keychain probe did not finish within {join_budget:.1f}s.",
163+
"details": {"probe_timed_out": True},
164+
}
165+
backends = [keychain_entry]
135166
available = [b["backend"] for b in backends if b.get("available")]
136167
recommended = (
137-
DEFAULT_PERSISTENT_STORAGE
138-
if DEFAULT_PERSISTENT_STORAGE in available
139-
else ("1password" if "1password" in available else None)
168+
DEFAULT_PERSISTENT_STORAGE if DEFAULT_PERSISTENT_STORAGE in available else None
140169
)
141170
for b in backends:
142171
b["selected_by_default"] = b.get("backend") == recommended
@@ -154,17 +183,12 @@ def _resolve_storage_backend(
154183
clean = str(requested or "auto").strip().lower() or "auto"
155184
current = str(current_backend or "").strip().lower() or None
156185
if clean not in {"auto", *SUPPORTED_STORAGE_BACKENDS}:
157-
raise LabbookError("storage must be one of: auto, keychain, 1password.")
186+
raise LabbookError("storage must be one of: auto, keychain.")
158187
if clean == "auto":
159188
if current in available:
160189
return current, backends
161-
rec = (
162-
DEFAULT_PERSISTENT_STORAGE
163-
if DEFAULT_PERSISTENT_STORAGE in available
164-
else ("1password" if "1password" in available else None)
165-
)
166-
if rec:
167-
return rec, backends
190+
if DEFAULT_PERSISTENT_STORAGE in available:
191+
return DEFAULT_PERSISTENT_STORAGE, backends
168192
raise LabbookError(
169193
f"No supported secret storage backends are available. Set {TOKEN_ENV_VAR} instead."
170194
)
@@ -182,7 +206,7 @@ def _configured_storage(session_payload: dict[str, Any] | None) -> str | None:
182206
if not isinstance(session_payload, dict):
183207
return None
184208
v = str(session_payload.get("storage") or "").strip().lower()
185-
return v if v in {"keychain", "1password"} else None
209+
return v if v in set(SUPPORTED_STORAGE_BACKENDS) else None
186210

187211

188212
# ---------------------------------------------------------------------------
@@ -204,18 +228,14 @@ def _token_context(project_root: str | Path | None = None) -> dict[str, Any]:
204228
"storage": backend,
205229
"env_token_present": True,
206230
"keyring_error": None,
207-
"onepassword_error": None,
208231
"storage_error": None,
209232
}
210-
keyring_error = onepassword_error = None
233+
keyring_error: str | None = None
211234
token: str | None = None
212235
token_source: str | None = None
213236
if backend == "keychain":
214237
token, keyring_error = keychain_retrieve_token(session)
215238
token_source = "keychain" if token else None
216-
elif backend == "1password":
217-
token, onepassword_error = op_retrieve_token(session)
218-
token_source = "1password" if token else None
219239
return {
220240
"project_root": root,
221241
"session": session,
@@ -224,8 +244,7 @@ def _token_context(project_root: str | Path | None = None) -> dict[str, Any]:
224244
"storage": backend,
225245
"env_token_present": False,
226246
"keyring_error": keyring_error,
227-
"onepassword_error": onepassword_error,
228-
"storage_error": keyring_error or onepassword_error,
247+
"storage_error": keyring_error,
229248
}
230249

231250

@@ -258,20 +277,12 @@ def _secret_plan(
258277
token_source = token_context.get("token_source")
259278
env_token_present = bool(token_context.get("env_token_present"))
260279

261-
if configured_storage in {"keychain", "1password"} and token_source == configured_storage:
262-
if configured_storage == "keychain":
263-
return {
264-
"mode": "keychain",
265-
"reason": (
266-
"This project is already using the local system keychain successfully, "
267-
"so keeping that backend is the least disruptive option."
268-
),
269-
}
280+
if configured_storage == "keychain" and token_source == "keychain":
270281
return {
271-
"mode": "1password",
282+
"mode": "keychain",
272283
"reason": (
273-
"This project is already using 1Password successfully, so keeping that "
274-
"backend is the least disruptive option."
284+
"This project is already using the local system keychain successfully, "
285+
"so keeping that backend is the least disruptive option."
275286
),
276287
}
277288

@@ -287,18 +298,6 @@ def _secret_plan(
287298
)
288299
return {"mode": "keychain", "reason": reason}
289300

290-
if storage_default == "1password":
291-
reason = (
292-
"1Password is the only supported local secret backend detected, so it is "
293-
"the best persistent option here."
294-
)
295-
if env_token_present:
296-
reason += (
297-
f" The current process is using {TOKEN_ENV_VAR}, but 1Password is the "
298-
"better long-lived default."
299-
)
300-
return {"mode": "1password", "reason": reason}
301-
302301
return {
303302
"mode": "env",
304303
"reason": (
@@ -320,9 +319,7 @@ def prepare_internal_integration(
320319
storage_options = _available_storage_backends()
321320
available = [b["backend"] for b in storage_options if b.get("available")]
322321
storage_default = (
323-
DEFAULT_PERSISTENT_STORAGE
324-
if DEFAULT_PERSISTENT_STORAGE in available
325-
else ("1password" if "1password" in available else None)
322+
DEFAULT_PERSISTENT_STORAGE if DEFAULT_PERSISTENT_STORAGE in available else None
326323
)
327324
cmd = (
328325
f"uvx agent-labbook configure-secret --storage {storage_default}"
@@ -345,7 +342,7 @@ def prepare_internal_integration(
345342
"Create a Notion Internal Integration and share the target pages or data sources with it.",
346343
f"Run `{cmd}` for a local hidden prompt."
347344
if cmd
348-
else f"If no local secret backend is available, use NOTION_AGENT_LABBOOK_TOKEN as a process-scoped override.",
345+
else "If no local secret backend is available, use NOTION_AGENT_LABBOOK_TOKEN as a process-scoped override.",
349346
"Bind the resources you want to use.",
350347
"Call notion_get_api_context only when you are ready to use the official Notion API.",
351348
],
@@ -359,8 +356,6 @@ def configure_internal_integration(
359356
secret: str,
360357
project_root: str | Path | None = None,
361358
storage: str | None = None,
362-
op_vault: str | None = None,
363-
op_item_title: str | None = None,
364359
) -> dict[str, Any]:
365360
root = resolve_project_root(project_root)
366361
clean_secret = str(secret or "").strip()
@@ -390,15 +385,6 @@ def configure_internal_integration(
390385
ks, ka = keychain_store_token(project_root=root, token=clean_secret)
391386
kw["keyring_service"] = ks
392387
kw["keyring_account"] = ka
393-
elif resolved_backend == "1password":
394-
kw.update(
395-
op_store_token(
396-
project_root=root,
397-
token=clean_secret,
398-
vault=op_vault,
399-
item_title=op_item_title,
400-
)
401-
)
402388
else:
403389
raise LabbookError(f"Unsupported storage backend: {resolved_backend}")
404390
save_project_session(root, _session_payload(**kw))
@@ -414,8 +400,6 @@ def configure_internal_integration(
414400
"bot_owner_type": bot_owner_type,
415401
"storage_options": storage_options,
416402
"recommended_next_action": "notion_search_resources",
417-
"op_vault": kw.get("op_vault"),
418-
"op_item_title": kw.get("op_item_title"),
419403
"keyring_backend": keychain_backend_name(),
420404
}
421405

@@ -434,9 +418,7 @@ def status(project_root: str | Path | None = None) -> dict[str, Any]:
434418
storage_options = _available_storage_backends()
435419
available = [b["backend"] for b in storage_options if b.get("available")]
436420
storage_default = (
437-
DEFAULT_PERSISTENT_STORAGE
438-
if DEFAULT_PERSISTENT_STORAGE in available
439-
else ("1password" if "1password" in available else None)
421+
DEFAULT_PERSISTENT_STORAGE if DEFAULT_PERSISTENT_STORAGE in available else None
440422
)
441423
secret_plan = _secret_plan(token_context=tc, storage_default=storage_default)
442424
authenticated = bool(tc["token"])
@@ -457,7 +439,6 @@ def status(project_root: str | Path | None = None) -> dict[str, Any]:
457439
"env_token_present": tc["env_token_present"],
458440
"keyring_backend": keychain_backend_name(),
459441
"keyring_error": tc["keyring_error"],
460-
"onepassword_error": tc["onepassword_error"],
461442
"storage_error": tc["storage_error"],
462443
"storage_options": storage_options,
463444
"storage_default": storage_default,
@@ -506,8 +487,6 @@ def clear_project_auth(
506487
deleted = False
507488
if storage == "keychain":
508489
deleted = keychain_delete_token(session)
509-
elif storage == "1password":
510-
deleted = op_delete_token(session)
511490
session_cleared = clear_project_session(root)
512491
bindings_cleared = clear_project_bindings(root) if clear_bindings else False
513492
return {

0 commit comments

Comments
 (0)