forked from jason/echo
ver 1.3 and 1.3.1
This commit is contained in:
@@ -19,9 +19,11 @@ reused across requests), and `read_many` fans bulk GETs across a thread pool —
|
||||
full-vault scripts (sweep, lint, recall rebuild) make a handful of warm round-trips
|
||||
instead of hundreds of fresh TLS handshakes, and no longer blow past tool timeouts.
|
||||
|
||||
Config (env overrides; defaults match the rest of the plugin):
|
||||
ECHO_BASE default https://echoapi.alwisp.com
|
||||
ECHO_KEY default the plugin bearer token (env overrides it)
|
||||
Config (owner/endpoint/key are machine-local — see echo_config; the plugin ships none):
|
||||
owner/endpoint/key resolved by echo_config from ~/.claude/echo-memory/config.json
|
||||
(env ECHO_OWNER / ECHO_BASE / ECHO_KEY override per field).
|
||||
Set up a machine with `echo.py config init` then edit, or
|
||||
`echo.py config set --owner ... --endpoint ... --key ...`.
|
||||
ECHO_VERIFY default 1 — read-back verify after a PUT
|
||||
ECHO_LOCK_TTL default 900 — seconds before an advisory lock is considered stale
|
||||
ECHO_TIMEOUT default 30 — per-request socket timeout (seconds)
|
||||
@@ -45,8 +47,9 @@ Usage:
|
||||
echo.py unlock <owner-id> # release advisory lock if owned by <owner-id>
|
||||
echo.py scope show # print active scope, its freshness, and sessions-since
|
||||
echo.py scope set "<text>" # switch scope atomically (history + replace + stamp scope_updated)
|
||||
echo.py config [show|init|set|import] # machine-local owner/endpoint/key (config import <file> adopts a provided file)
|
||||
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 2 usage · 1 other HTTP/transport error.
|
||||
Exit codes: 0 ok · 44 not-found(404) · 75 lock-held/lost · 78 config-required · 2 usage · 1 other HTTP/transport error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -73,15 +76,17 @@ for _stream in (sys.stdout, sys.stderr):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
BASE = os.environ.get("ECHO_BASE", "https://echoapi.alwisp.com").rstrip("/")
|
||||
|
||||
# Key resolution is centralized in echo_secrets: ECHO_KEY env -> ~/.echo-memory/credentials
|
||||
# -> the deprecated baked-in fallback below. Move the key out of the tree with
|
||||
# `echo.py write-key <token>`; remove DEFAULT_KEY before the 1.0 tag (see ROADMAP-1.0.md > M1).
|
||||
# Owner / endpoint / key are machine-local and resolved by echo_config from
|
||||
# ~/.claude/echo-memory/config.json (env ECHO_OWNER / ECHO_BASE / ECHO_KEY override).
|
||||
# The plugin ships none of them; load() returns "" for anything unset so --help,
|
||||
# `config`, and `doctor` stay usable. Network ops raise a clear error if endpoint/key
|
||||
# are missing (see _require_endpoint / request).
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import echo_secrets # noqa: E402
|
||||
DEFAULT_KEY = "241265fbe6830934a9a4ad3e69335f64a42153b663aa5b0017cb1ea1217b2bab"
|
||||
KEY = echo_secrets.resolve_key(DEFAULT_KEY)
|
||||
import echo_config # noqa: E402
|
||||
_CFG = echo_config.load()
|
||||
BASE = _CFG["endpoint"]
|
||||
KEY = _CFG["key"]
|
||||
OWNER = _CFG["owner"]
|
||||
VERIFY = os.environ.get("ECHO_VERIFY", "1") != "0"
|
||||
LOCK_TTL = int(os.environ.get("ECHO_LOCK_TTL", "900"))
|
||||
# Per-request socket timeout. A single stuck file fails fast instead of stalling a
|
||||
@@ -125,6 +130,19 @@ _local = threading.local()
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _require_config() -> None:
|
||||
"""Fail loudly (not with an opaque DNS/connection error) when this machine has no
|
||||
usable key file yet — the most common fresh-install state. Also catches an
|
||||
untouched `config init` template (placeholder endpoint/key)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
raise EchoError(
|
||||
"echo: NOT CONFIGURED — no usable ECHO key file on this machine. Ask the "
|
||||
f"operator for their config (owner/endpoint/key) and install it at "
|
||||
f"{echo_config.config_path()}: `echo.py config import <file>`, or "
|
||||
"`echo.py config set --owner ... --endpoint ... --key ...` "
|
||||
"(or set ECHO_BASE / ECHO_KEY).", 2)
|
||||
|
||||
|
||||
def _new_connection() -> http.client.HTTPConnection:
|
||||
parts = urllib.parse.urlsplit(BASE)
|
||||
host = parts.hostname or parts.path
|
||||
@@ -157,6 +175,7 @@ def request(method: str, url: str, data: bytes | None = None,
|
||||
status 0 == transport failure (echo_queue relies on this to trigger offline queueing).
|
||||
Retries: a stale pooled connection reconnects immediately; 5xx and other transport
|
||||
errors get one bounded sleep-retry, same as before."""
|
||||
_require_config()
|
||||
merged = {"Authorization": f"Bearer {KEY}", **(headers or {})}
|
||||
parts = urllib.parse.urlsplit(url)
|
||||
target = parts.path or "/"
|
||||
@@ -514,6 +533,15 @@ def cmd_scope(subcommand: str, text: str | None = None) -> int:
|
||||
def cmd_load() -> int:
|
||||
"""Cold-start orientation: the canonical 6 reads in one call. 404s on today's
|
||||
daily note and the inbox are normal (printed as absent, not errors)."""
|
||||
if not echo_config.is_configured(_CFG):
|
||||
print("echo: NOT CONFIGURED — this machine has no usable ECHO key file yet.")
|
||||
print(f"Expected at: {echo_config.config_path()}")
|
||||
print("ACTION: ask the operator for their echo-memory config file (it holds the")
|
||||
print(" vault owner, endpoint, and API key), then install it with ONE of:")
|
||||
print(" python3 echo.py config import <path-to-their-file>")
|
||||
print(" python3 echo.py config set --owner \"…\" --endpoint \"https://…\" --key \"…\"")
|
||||
print("Then re-run load. (Memory is unavailable until configured.)")
|
||||
return 78 # distinct: configuration required
|
||||
targets = [
|
||||
("marker", "_agent/echo-vault.md"),
|
||||
("preferences", "_agent/memory/semantic/operator-preferences.md"),
|
||||
@@ -588,6 +616,44 @@ def cmd_load() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_config(args) -> int:
|
||||
"""show | init | set the machine-local owner/endpoint/key config."""
|
||||
path = echo_config.config_path()
|
||||
if args.action == "init":
|
||||
p, created = echo_config.init_template()
|
||||
print(f"ok: wrote config template to {p} — edit it with your owner/endpoint/key"
|
||||
if created else f"config already exists at {p} (left unchanged)")
|
||||
return 0
|
||||
if args.action == "import":
|
||||
if not args.path:
|
||||
print("config import: pass the path to a config file "
|
||||
"(JSON with owner/endpoint/key)", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
p = echo_config.import_file(args.path)
|
||||
except RuntimeError as exc:
|
||||
print(exc, file=sys.stderr)
|
||||
return 2
|
||||
print(f"ok: imported config to {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
if args.action == "set":
|
||||
if args.owner is None and args.endpoint is None and args.key is None:
|
||||
print("config set: pass at least one of --owner / --endpoint / --key", file=sys.stderr)
|
||||
return 2
|
||||
p = echo_config.write_config(args.owner, args.endpoint, args.key)
|
||||
print(f"ok: updated {p} (chmod 600 best-effort)")
|
||||
return 0
|
||||
# show — never prints the key in the clear
|
||||
cfg = echo_config.load()
|
||||
key = cfg["key"]
|
||||
redacted = (key[:4] + "…" + key[-4:]) if len(key) >= 12 else ("set" if key else "")
|
||||
print(f"config file: {path}" + ("" if path.exists() else " (absent)"))
|
||||
for field, shown in (("owner", cfg["owner"]), ("endpoint", cfg["endpoint"]), ("key", redacted)):
|
||||
src = echo_config.source(field)
|
||||
print(f" {field:9} {shown or '(unset)'} [{src}]")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Validated cross-platform ECHO vault client")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
@@ -609,7 +675,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
sub.add_parser("delete").add_argument("path")
|
||||
sub.add_parser("lock").add_argument("owner")
|
||||
sub.add_parser("unlock").add_argument("owner")
|
||||
sub.add_parser("write-key").add_argument("token") # M1: move the key to ~/.echo-memory/credentials
|
||||
p = sub.add_parser("config") # machine-local owner/endpoint/key (see echo_config)
|
||||
p.add_argument("action", nargs="?", default="show", choices=["show", "init", "set", "import"])
|
||||
p.add_argument("path", nargs="?") # for `config import <path>`
|
||||
p.add_argument("--owner"); p.add_argument("--endpoint"); p.add_argument("--key")
|
||||
p = sub.add_parser("scope")
|
||||
p.add_argument("subcommand", nargs="?", default="show", choices=["show", "set"])
|
||||
p.add_argument("text", nargs="?")
|
||||
@@ -657,10 +726,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
print("ok: queue empty")
|
||||
return 0
|
||||
if args.cmd == "write-key":
|
||||
path = echo_secrets.write_key(args.token)
|
||||
print(f"ok: wrote credentials to {path} (chmod 600 best-effort); ECHO_KEY env still overrides")
|
||||
return 0
|
||||
if args.cmd == "config":
|
||||
return cmd_config(args)
|
||||
if args.cmd == "get":
|
||||
return cmd_get(args.path)
|
||||
if args.cmd == "map":
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_config.py — resolve the vault owner, endpoint, and API key for ECHO.
|
||||
|
||||
The plugin ships NO owner, endpoint, or key — it is fully user-agnostic. On each
|
||||
machine, a machine-local JSON config supplies them:
|
||||
|
||||
~/.claude/echo-memory/config.json
|
||||
{
|
||||
"owner": "Full Name (how the agent refers to the vault owner, third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>"
|
||||
}
|
||||
|
||||
This file is per-machine: it must be created on each fresh install, is never
|
||||
committed, and is never written into a vault note. To create it, run
|
||||
`python3 echo.py config init` (writes a placeholder template when absent) and edit
|
||||
it, or `python3 echo.py config set --owner ... --endpoint ... --key ...`.
|
||||
|
||||
Per-field resolution (first hit wins):
|
||||
owner env ECHO_OWNER -> config "owner"
|
||||
endpoint env ECHO_BASE -> config "endpoint"
|
||||
key env ECHO_KEY -> config "key"
|
||||
|
||||
The config directory honors $CLAUDE_CONFIG_DIR (defaults to ~/.claude); the config
|
||||
file path itself can be overridden with $ECHO_CONFIG. Pure stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
TEMPLATE = {
|
||||
"owner": "Full Name — how the agent should refer to the vault owner (third person)",
|
||||
"endpoint": "https://your-obsidian-rest-endpoint.example.com",
|
||||
"key": "<obsidian-local-rest-api-bearer-token>",
|
||||
}
|
||||
|
||||
|
||||
def claude_dir() -> Path:
|
||||
"""The Claude config directory ($CLAUDE_CONFIG_DIR, else ~/.claude)."""
|
||||
return Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
"""Absolute path to the machine-local config file."""
|
||||
override = os.environ.get("ECHO_CONFIG")
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
return claude_dir() / "echo-memory" / "config.json"
|
||||
|
||||
|
||||
def _from_file() -> dict:
|
||||
p = config_path()
|
||||
if not p.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
"""Return {owner, endpoint, key} with env overriding the file. Missing -> "".
|
||||
|
||||
Never raises — callers that REQUIRE a field use resolve_endpoint()/resolve_key(),
|
||||
which raise a helpful error. This keeps `--help`, `config`, and `doctor` usable
|
||||
even when nothing is configured yet."""
|
||||
f = _from_file()
|
||||
endpoint = (os.environ.get("ECHO_BASE") or f.get("endpoint") or "").rstrip("/")
|
||||
return {
|
||||
"owner": os.environ.get("ECHO_OWNER") or f.get("owner") or "",
|
||||
"endpoint": endpoint,
|
||||
"key": os.environ.get("ECHO_KEY") or f.get("key") or "",
|
||||
}
|
||||
|
||||
|
||||
def _missing(field: str, env: str) -> RuntimeError:
|
||||
return RuntimeError(
|
||||
f"echo: no {field} configured. Set {env}, or create {config_path()} "
|
||||
f"(run `echo.py config init` to scaffold it, then edit). "
|
||||
f'Expected JSON keys: "owner", "endpoint", "key".'
|
||||
)
|
||||
|
||||
|
||||
def resolve_endpoint() -> str:
|
||||
ep = load()["endpoint"]
|
||||
if not ep:
|
||||
raise _missing("endpoint", "ECHO_BASE")
|
||||
return ep
|
||||
|
||||
|
||||
def resolve_key() -> str:
|
||||
k = load()["key"]
|
||||
if not k:
|
||||
raise _missing("key", "ECHO_KEY")
|
||||
return k
|
||||
|
||||
|
||||
def resolve_owner() -> str:
|
||||
"""The owner display name. Descriptive only, so an empty value is tolerated."""
|
||||
return load()["owner"]
|
||||
|
||||
|
||||
def is_configured(cfg: dict | None = None) -> bool:
|
||||
"""True only when endpoint AND key are present and NOT the untouched template
|
||||
placeholders — so a freshly `config init`-ed (but unedited) file reads as not yet
|
||||
configured, which is what triggers the first-run "ask for the key file" prompt."""
|
||||
c = cfg if cfg is not None else load()
|
||||
ep, key = c.get("endpoint", ""), c.get("key", "")
|
||||
if not ep or not key:
|
||||
return False
|
||||
if "your-obsidian-rest-endpoint" in ep or key.startswith("<"):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def source(field: str) -> str:
|
||||
"""Where a field is currently coming from — for `config`/`doctor` reporting."""
|
||||
env = {"owner": "ECHO_OWNER", "endpoint": "ECHO_BASE", "key": "ECHO_KEY"}[field]
|
||||
if os.environ.get(env):
|
||||
return f"{env} env"
|
||||
if config_path().exists() and _from_file().get(field):
|
||||
return "config file"
|
||||
return "unset"
|
||||
|
||||
|
||||
def template_text() -> str:
|
||||
return json.dumps(TEMPLATE, indent=2, ensure_ascii=False) + "\n"
|
||||
|
||||
|
||||
def _secure(p: Path) -> None:
|
||||
try:
|
||||
os.chmod(p, 0o600) # advisory on filesystems without POSIX mode bits (e.g. Windows)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_config(owner: str, endpoint: str, key: str) -> Path:
|
||||
"""Persist a filled-in config (merging over any existing values), chmod 0600."""
|
||||
cur = _from_file()
|
||||
merged = {
|
||||
"owner": owner if owner is not None else cur.get("owner", ""),
|
||||
"endpoint": (endpoint if endpoint is not None else cur.get("endpoint", "")).rstrip("/"),
|
||||
"key": key if key is not None else cur.get("key", ""),
|
||||
}
|
||||
p = config_path()
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
_secure(p)
|
||||
return p
|
||||
|
||||
|
||||
def import_file(src) -> Path:
|
||||
"""Adopt a config file someone was handed: validate it has a usable endpoint+key,
|
||||
then copy it into config_path(). The 'provide the file -> plugin installs it' path."""
|
||||
p = Path(src).expanduser()
|
||||
if not p.exists():
|
||||
raise RuntimeError(f"config import: file not found: {p}")
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise RuntimeError(f"config import: {p} is not valid JSON ({exc})")
|
||||
if not isinstance(data, dict) or not is_configured(data):
|
||||
raise RuntimeError(
|
||||
'config import: file must be JSON with a real "endpoint" and "key" '
|
||||
'(and ideally "owner") — not the template placeholders.')
|
||||
return write_config(data.get("owner", ""), data["endpoint"], data["key"])
|
||||
|
||||
|
||||
def init_template() -> tuple[Path, bool]:
|
||||
"""Write the placeholder template to config_path() if absent.
|
||||
Returns (path, created) — created is False when a config already exists."""
|
||||
p = config_path()
|
||||
if p.exists():
|
||||
return p, False
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(template_text(), encoding="utf-8")
|
||||
_secure(p)
|
||||
return p, True
|
||||
@@ -13,7 +13,6 @@ Exit: 0 all green · 1 a check is red.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -31,13 +30,35 @@ def run() -> int:
|
||||
reds += 0 if ok else 1
|
||||
print(f" [{'OK ' if ok else 'RED'}] {label}" + (f" — {detail}" if detail else ""))
|
||||
|
||||
print(f"echo doctor — endpoint {echo.BASE}")
|
||||
import echo_config
|
||||
cfg = echo_config.load()
|
||||
print(f"echo doctor — endpoint {cfg['endpoint'] or '(not configured)'}")
|
||||
|
||||
# 1. interpreter
|
||||
line(sys.version_info >= MIN_PY, f"python >= {MIN_PY[0]}.{MIN_PY[1]}",
|
||||
f"running {sys.version_info.major}.{sys.version_info.minor}")
|
||||
|
||||
# 2. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
# 2. machine-local config (owner/endpoint/key). Without a usable endpoint+key
|
||||
# nothing else can run, so report it clearly before touching the network.
|
||||
# A still-placeholder `config init` template counts as not configured.
|
||||
ep_real = bool(cfg["endpoint"]) and "your-obsidian-rest-endpoint" not in cfg["endpoint"]
|
||||
key_real = bool(cfg["key"]) and not cfg["key"].startswith("<")
|
||||
line(ep_real, "endpoint configured",
|
||||
f"[{echo_config.source('endpoint')}]" if ep_real
|
||||
else (f"placeholder — edit {echo_config.config_path()}" if cfg["endpoint"]
|
||||
else f"create {echo_config.config_path()} (run `echo.py config init`)"))
|
||||
line(key_real, "API key configured",
|
||||
f"[{echo_config.source('key')}]" if key_real
|
||||
else ("placeholder — not yet filled in" if cfg["key"] else "missing — see `echo.py config`"))
|
||||
line(bool(cfg["owner"]), "vault owner set",
|
||||
cfg["owner"] if cfg["owner"] else "optional, but recommended for third-person writes")
|
||||
if not echo_config.is_configured(cfg):
|
||||
print("\ndoctor: not configured — ask the operator for their key file and install it "
|
||||
"(`echo.py config import <file>`, or `config set --owner … --endpoint … --key …`), "
|
||||
"then re-run.")
|
||||
return 1
|
||||
|
||||
# 3. reachability + auth + marker, in one GET of the bootstrap marker
|
||||
status, body = echo.request("GET", echo.vault_url("_agent/echo-vault.md"))
|
||||
if status == 0:
|
||||
line(False, "vault reachable", body.decode(errors="replace")[:120])
|
||||
@@ -54,13 +75,7 @@ def run() -> int:
|
||||
else:
|
||||
line(status < 400, "marker fetch", f"HTTP {status}")
|
||||
|
||||
# 3. key source (M1) + invariants pointer.
|
||||
import echo_secrets
|
||||
key_src = ("ECHO_KEY env" if os.environ.get("ECHO_KEY")
|
||||
else "credentials file" if (echo_secrets._state_dir() / "credentials").exists()
|
||||
else "baked-in fallback (deprecated — see API-KEY-SETUP.md)")
|
||||
line(os.environ.get("ECHO_KEY") is not None or (echo_secrets._state_dir() / "credentials").exists(),
|
||||
"API key source", key_src)
|
||||
# 4. invariants pointer.
|
||||
print(" [i] invariants — run `/echo-health` (vault_lint.py) for the full check")
|
||||
|
||||
print(f"\ndoctor: {'all green' if reds == 0 else f'{reds} issue(s) — see RED above'}")
|
||||
|
||||
@@ -21,7 +21,7 @@ import re
|
||||
MIN_NAME_LEN = 4
|
||||
_COMMON = frozenset(
|
||||
"api app data note task user team work code test main repo file plan goal item "
|
||||
"the and for with mpm".split()
|
||||
"the and for with from".split()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ PROPOSAL_SCHEMA (one per durable item the agent wants to remember):
|
||||
{
|
||||
"title": "Bob Smith", # required
|
||||
"kind": "person", # required unless "inbox": true
|
||||
"body": "Principal at MPM; ...", # markdown body (optional)
|
||||
"body": "Principal at Acme; ...", # markdown body (optional)
|
||||
"aliases": ["bob", "rs"], # optional
|
||||
"sources": ["_agent/sessions/..."], # optional backward links
|
||||
"date": "YYYY-MM-DD", # optional (meeting/decision kinds)
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""echo_secrets.py — M1: resolve the API bearer token without shipping it in source.
|
||||
[v1.0 SCAFFOLD — resolve_key() implemented; wiring into echo.py is the integration step]
|
||||
|
||||
Problem: the token is hardcoded at echo.py:70 and repeated through api-reference.md /
|
||||
README — committed to git and shipped inside every .plugin zip, rotatable only by a
|
||||
rebuild. The plugin's own safety rule is "never store secrets". This resolves the key
|
||||
from progressively less-trusted sources, with a loud warning if the legacy baked
|
||||
default is ever reached.
|
||||
|
||||
RESOLUTION ORDER (first hit wins):
|
||||
1. env ECHO_KEY — preferred; CI / per-session override.
|
||||
2. ECHO_STATE_DIR/credentials — local file, one `key=...` line, chmod 0600.
|
||||
3. legacy baked default (deprecated) — warns to stderr; remove before 1.0 ship.
|
||||
|
||||
Pure stdlib only (no `keyring`); the local file is the cross-platform stand-in for an
|
||||
OS keychain and keeps the secret OUT of the tracked tree.
|
||||
|
||||
INTEGRATION (do at merge time):
|
||||
echo.py: KEY = echo_secrets.resolve_key()
|
||||
docs: replace the literal token with `<ECHO_KEY>` placeholders
|
||||
rotate: document `echo write-key` (writes the credentials file) — see TODO below
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Kept ONLY so a fresh install still works during the migration window; delete for 1.0.
|
||||
_LEGACY_DEFAULT = "" # intentionally blank in the scaffold — do not re-introduce the literal
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
return Path(os.environ.get("ECHO_STATE_DIR") or (Path.home() / ".echo-memory"))
|
||||
|
||||
|
||||
def _from_file() -> str | None:
|
||||
p = _state_dir() / "credentials"
|
||||
if not p.exists():
|
||||
return None
|
||||
for line in p.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("key=") or line.startswith("ECHO_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_key(legacy: str | None = None) -> str:
|
||||
"""Return the bearer token from the most trusted available source:
|
||||
ECHO_KEY env -> credentials file -> `legacy` baked-in fallback (with a loud, once-per-
|
||||
process warning that can be silenced via ECHO_KEY_LEGACY_OK=1). Raises if none exist."""
|
||||
env = os.environ.get("ECHO_KEY")
|
||||
if env:
|
||||
return env
|
||||
from_file = _from_file()
|
||||
if from_file:
|
||||
return from_file
|
||||
legacy = legacy or _LEGACY_DEFAULT
|
||||
if legacy:
|
||||
if os.environ.get("ECHO_KEY_LEGACY_OK") != "1":
|
||||
print("echo_secrets: WARNING — using the deprecated baked-in API key. Run "
|
||||
"`echo.py write-key <token>` (stores ~/.echo-memory/credentials) or set "
|
||||
"ECHO_KEY; set ECHO_KEY_LEGACY_OK=1 to silence.", file=sys.stderr)
|
||||
return legacy
|
||||
raise RuntimeError(
|
||||
"echo_secrets: no API key found. Set ECHO_KEY or create "
|
||||
f"{_state_dir() / 'credentials'} with a 'key=<token>' line."
|
||||
)
|
||||
|
||||
|
||||
def write_key(token: str) -> Path:
|
||||
"""Persist the token to the local credentials file (one `key=...` line, chmod 0600).
|
||||
Best-effort perms — Windows has no POSIX mode bits, so the chmod is advisory there."""
|
||||
d = _state_dir()
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
path = d / "credentials"
|
||||
path.write_text(f"key={token.strip()}\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError:
|
||||
pass # platform without POSIX perms (e.g. some Windows filesystems)
|
||||
return path
|
||||
@@ -158,9 +158,9 @@ def test_aliases_in_frontmatter_flow_and_block() -> None:
|
||||
|
||||
def test_links_add_related_is_idempotent_and_bidirectional_token() -> None:
|
||||
text = "---\ntype: person\n---\n\n# Bob\n\n## Related\n"
|
||||
new, changed = echo_links.add_related(text, "resources/companies/mpm.md")
|
||||
assert changed and "[[resources/companies/mpm]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/mpm.md")
|
||||
new, changed = echo_links.add_related(text, "resources/companies/acme.md")
|
||||
assert changed and "[[resources/companies/acme]]" in new
|
||||
again, changed2 = echo_links.add_related(new, "resources/companies/acme.md")
|
||||
assert not changed2 and again == new
|
||||
|
||||
|
||||
|
||||
@@ -76,19 +76,45 @@ def main() -> int:
|
||||
check("H5 classify/preview/apply wired",
|
||||
all(callable(getattr(rf, n, None)) for n in ("classify", "preview", "apply")))
|
||||
|
||||
# M1 — secret resolution order is implemented; env wins, then the credentials file.
|
||||
import echo_secrets as s
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("M1 env ECHO_KEY wins", s.resolve_key("legacy-fallback") == "env-key-123")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
os.environ["ECHO_STATE_DIR"] = d
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
s.write_key("file-key-xyz")
|
||||
check("M1 write_key -> credentials -> resolve_key reads it", s.resolve_key() == "file-key-xyz")
|
||||
os.environ["ECHO_KEY_LEGACY_OK"] = "1" # silence the warning for the legacy-path check
|
||||
os.environ["ECHO_STATE_DIR"] = os.path.join(d, "empty") # an empty dir: no creds file
|
||||
check("M1 legacy fallback when env+file absent", s.resolve_key("legacy-fallback") == "legacy-fallback")
|
||||
os.environ["ECHO_KEY"] = "env-key-123" # restore for any later checks
|
||||
# Config — owner/endpoint/key resolve from env -> machine-local config.json (no baked
|
||||
# defaults). Env wins per field; the file fills the rest; missing required -> raises.
|
||||
import echo_config as cfg
|
||||
saved_env = {k: os.environ.get(k) for k in ("ECHO_KEY", "ECHO_BASE", "ECHO_OWNER", "ECHO_CONFIG")}
|
||||
try:
|
||||
for k in saved_env:
|
||||
os.environ.pop(k, None)
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
cfgfile = os.path.join(d, "config.json")
|
||||
os.environ["ECHO_CONFIG"] = cfgfile
|
||||
# init writes the placeholder template only when absent.
|
||||
p, created = cfg.init_template()
|
||||
check("config init scaffolds the template", created and os.path.exists(cfgfile))
|
||||
p2, created2 = cfg.init_template()
|
||||
check("config init is idempotent (no clobber)", created2 is False)
|
||||
# write_config persists a filled-in config; load() reads it back.
|
||||
cfg.write_config("Owner Name", "https://obsidian.example.com/", "file-key-xyz")
|
||||
loaded = cfg.load()
|
||||
check("config file round-trips owner/endpoint/key",
|
||||
loaded == {"owner": "Owner Name",
|
||||
"endpoint": "https://obsidian.example.com", # trailing / stripped
|
||||
"key": "file-key-xyz"})
|
||||
# env overrides the file, per field.
|
||||
os.environ["ECHO_KEY"] = "env-key-123"
|
||||
check("env ECHO_KEY overrides config", cfg.resolve_key() == "env-key-123")
|
||||
os.environ.pop("ECHO_KEY", None)
|
||||
# missing required field raises a helpful error.
|
||||
cfg.write_config("Owner Name", "", "")
|
||||
try:
|
||||
cfg.resolve_key()
|
||||
check("missing key raises", False)
|
||||
except RuntimeError:
|
||||
check("missing key raises", True)
|
||||
finally:
|
||||
for k, v in saved_env.items():
|
||||
if v is None:
|
||||
os.environ.pop(k, None)
|
||||
else:
|
||||
os.environ[k] = v
|
||||
|
||||
# M2 — slug collision + link-confidence helpers are implemented.
|
||||
import echo_quality as qual
|
||||
|
||||
Reference in New Issue
Block a user