forked from bchanot/claude
feat(seo): account-management verbs (connect/accounts/forget) + connect.sh wrapper
tokenstore remove/clear, fetch.sh forget dispatch, and a connect.sh wrapper that sources ~/.claude/.env internally and runs from any project. /seo now routes connect|accounts|forget before the audit flow; Makefile seo-connect delegates to the wrapper. Labels are guarded to shell-safe ASCII (POSIX case, whole-string, C-locale) as defense-in-depth; forget output states local removal is not a Google-side revocation.
This commit is contained in:
+57
-14
@@ -2,6 +2,7 @@
|
||||
"""Label-keyed OAuth refresh-token store. Atomic writes under an fcntl lock.
|
||||
No third-party deps — must run without the venv (used by the offline test path)."""
|
||||
import argparse, fcntl, json, os, tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def load(path):
|
||||
@@ -21,14 +22,34 @@ def list_accounts(path):
|
||||
def get_refresh_token(path, label):
|
||||
return load(path).get("accounts", {}).get(label, {}).get("refresh_token")
|
||||
|
||||
@contextmanager
|
||||
def _locked(path):
|
||||
"""Exclusive fcntl lock around a store mutation (serializes writers)."""
|
||||
lock_path = path + ".lock"
|
||||
with open(lock_path, "w") as lock:
|
||||
os.chmod(lock_path, 0o600) # defense-in-depth (empty flock handle, never holds token)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||
yield
|
||||
|
||||
def _atomic_write(path, data):
|
||||
"""tmp → fsync → chmod 0600 → atomic rename, in the store's directory."""
|
||||
dirpath = os.path.dirname(path) or "."
|
||||
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush(); os.fsync(f.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path) # atomic
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
|
||||
def save_account(path, label, refresh_token, scopes, properties):
|
||||
dirpath = os.path.dirname(path) or "."
|
||||
os.makedirs(dirpath, mode=0o700, exist_ok=True)
|
||||
os.chmod(dirpath, 0o700) # re-assert invariant (makedirs no-ops if dir exists)
|
||||
lock_path = path + ".lock"
|
||||
with open(lock_path, "w") as lock:
|
||||
os.chmod(lock_path, 0o600) # defense-in-depth (empty flock handle, never holds token)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX) # serialize concurrent connects
|
||||
with _locked(path):
|
||||
data = load(path)
|
||||
data.setdefault("version", 1)
|
||||
data.setdefault("accounts", {})
|
||||
@@ -38,16 +59,28 @@ def save_account(path, label, refresh_token, scopes, properties):
|
||||
"granted_at": datetime.now(timezone.utc).isoformat(),
|
||||
"properties": properties,
|
||||
}
|
||||
fd, tmp = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush(); os.fsync(f.fileno())
|
||||
os.chmod(tmp, 0o600)
|
||||
os.replace(tmp, path) # atomic
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
_atomic_write(path, data)
|
||||
|
||||
def remove_account(path, label):
|
||||
"""Drop one label from the store. Returns True if it existed."""
|
||||
if not os.path.exists(path):
|
||||
return False
|
||||
with _locked(path):
|
||||
data = load(path)
|
||||
existed = data.get("accounts", {}).pop(label, None) is not None
|
||||
if existed:
|
||||
_atomic_write(path, data)
|
||||
return existed
|
||||
|
||||
def clear_accounts(path):
|
||||
"""Empty the store (file and perms kept). Returns removed count."""
|
||||
if not os.path.exists(path):
|
||||
return 0
|
||||
with _locked(path):
|
||||
data = load(path)
|
||||
count = len(data.get("accounts", {}))
|
||||
_atomic_write(path, {"version": 1, "accounts": {}})
|
||||
return count
|
||||
|
||||
def _cli():
|
||||
p = argparse.ArgumentParser()
|
||||
@@ -58,10 +91,20 @@ def _cli():
|
||||
ps.add_argument(flag, required=True)
|
||||
ps.add_argument("--scopes", default="")
|
||||
ps.add_argument("--properties", default="")
|
||||
pr = sub.add_parser("remove")
|
||||
for flag in ("--file", "--label"):
|
||||
pr.add_argument(flag, required=True)
|
||||
pc = sub.add_parser("clear"); pc.add_argument("--file", required=True)
|
||||
try:
|
||||
args = p.parse_args()
|
||||
if args.cmd == "list":
|
||||
print(json.dumps({"status": "ok", "accounts": list_accounts(args.file)}))
|
||||
elif args.cmd == "remove":
|
||||
print(json.dumps({"status": "ok",
|
||||
"removed": remove_account(args.file, args.label)}))
|
||||
elif args.cmd == "clear":
|
||||
print(json.dumps({"status": "ok",
|
||||
"cleared": clear_accounts(args.file)}))
|
||||
else:
|
||||
save_account(args.file, args.label, getattr(args, "refresh_token"),
|
||||
[s for s in args.scopes.split(",") if s],
|
||||
|
||||
Reference in New Issue
Block a user