Move private runtime state outside checkouts and isolate worktrees
This commit is contained in:
@@ -47,7 +47,7 @@ _SCRIPT_DIR = pathlib.Path(__file__).parent
|
||||
if str(_SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_DIR))
|
||||
|
||||
from napseer_credentials import CredentialStore, credential_lock, CredentialError
|
||||
from napseer_credentials import CredentialStore, credential_lock, CredentialError, WorkspacePaths
|
||||
|
||||
class SafeToolError(RuntimeError):
|
||||
def __init__(self, code, message, *, status=None, service_code=None):
|
||||
@@ -195,10 +195,7 @@ class RelaySocketClosed(RuntimeError):
|
||||
|
||||
|
||||
def local_state_dir():
|
||||
project_root = os.environ.get("NAPSEER_PROJECT_ROOT")
|
||||
if project_root:
|
||||
return pathlib.Path(project_root).expanduser().resolve() / ".napseer"
|
||||
return pathlib.Path.cwd() / ".napseer"
|
||||
return WorkspacePaths().state
|
||||
|
||||
|
||||
def state_dir_status_payload():
|
||||
@@ -207,7 +204,10 @@ def state_dir_status_payload():
|
||||
"active_state_dir": str(active),
|
||||
"state_dir": str(active),
|
||||
"state_dir_exists": active.exists(),
|
||||
"state_dir_message": ".napseer directory is active.",
|
||||
"state_dir_message": "Private Napseer state is stored in user data.",
|
||||
"project_root": str(WORKSPACE.root),
|
||||
"project_locator_path": str(WORKSPACE.locator),
|
||||
"migration_required": bool(WORKSPACE.migration_sources()),
|
||||
}
|
||||
|
||||
|
||||
@@ -318,8 +318,10 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
|
||||
).start()
|
||||
|
||||
|
||||
AUTH_DIR = local_state_dir()
|
||||
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve()
|
||||
WORKSPACE = WorkspacePaths()
|
||||
PROJECT_DIR = WORKSPACE.legacy
|
||||
AUTH_DIR = WORKSPACE.state
|
||||
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth.json")).expanduser().absolute()
|
||||
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
|
||||
PROJECT_LOCATOR_FILENAME = "project.json"
|
||||
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
|
||||
@@ -333,7 +335,7 @@ CONTAINER_IDENTITY_PATH = AUTH_DIR / "container-identity.json"
|
||||
TELEMETRY_PATH = AUTH_DIR / "telemetry.json"
|
||||
INDEX_PATH = AUTH_DIR / "index.sqlite"
|
||||
INDEX_LOCK_PATH = AUTH_DIR / "index.lock"
|
||||
DEFAULT_KEY_PATH = AUTH_DIR / "id_ed25519"
|
||||
DEFAULT_KEY_PATH = WORKSPACE.signing_key
|
||||
SCRIPT_PATH = pathlib.Path(__file__).resolve()
|
||||
SCRIPT_NAME = SCRIPT_PATH.name
|
||||
SCRIPT_DEPENDENCY_NAMES = ("napseer_spake2.py", "napseer_credentials.py")
|
||||
@@ -815,7 +817,7 @@ def read_local_vault_key(create=False):
|
||||
return value
|
||||
if not create:
|
||||
return None
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
value = base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
||||
LOCAL_VAULT_KEY_PATH.write_text(value + "\n", encoding="utf-8")
|
||||
@@ -828,7 +830,15 @@ def vault_exists():
|
||||
|
||||
|
||||
def credential_store():
|
||||
return CredentialStore(AUTH_DIR, AUTH_PATH)
|
||||
return CredentialStore(PROJECT_DIR, AUTH_PATH)
|
||||
|
||||
|
||||
def active_auth_path():
|
||||
store = credential_store()
|
||||
try:
|
||||
return store.selected()[0]
|
||||
except CredentialError:
|
||||
return store.project # Destination only; status also reports migration_required.
|
||||
|
||||
|
||||
def load_public_auth_file():
|
||||
@@ -850,8 +860,8 @@ def write_public_auth(data):
|
||||
|
||||
|
||||
def project_locator_path():
|
||||
"""Return the repository-visible project locator beside private auth state."""
|
||||
return AUTH_DIR / PROJECT_LOCATOR_FILENAME
|
||||
"""Return the only repository-visible project data: the public locator."""
|
||||
return PROJECT_DIR / PROJECT_LOCATOR_FILENAME
|
||||
|
||||
|
||||
def validate_project_locator(data):
|
||||
@@ -1081,7 +1091,7 @@ def write_vault(passphrase, payload):
|
||||
key = _derive_vault_key(passphrase, salt, VAULT_KDF_ITERATIONS)
|
||||
plaintext = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
nonce, ciphertext, tag = _encrypt_vault_payload(key, plaintext, VAULT_SCHEMA.encode("utf-8"))
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
VAULT_PATH.write_text(
|
||||
json.dumps(
|
||||
@@ -1140,7 +1150,7 @@ def derive_gateway_relay_secret(passphrase):
|
||||
|
||||
|
||||
def write_gateway_relay_secret(passphrase):
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_RELAY_SECRET_PATH.write_text(
|
||||
json.dumps(
|
||||
@@ -1196,7 +1206,7 @@ def write_gateway_relay_state(**updates):
|
||||
current.update({key: value for key, value in updates.items() if value is not None})
|
||||
current["schema"] = GATEWAY_RELAY_STATE_SCHEMA
|
||||
current["updated_at"] = iso_now()
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_RELAY_STATE_PATH.write_text(json.dumps(current, indent=2) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_RELAY_STATE_PATH, 0o600)
|
||||
@@ -1325,7 +1335,7 @@ def ensure_container_uuid():
|
||||
"container_uuid": value,
|
||||
"created_at": iso_now(),
|
||||
}
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
CONTAINER_IDENTITY_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(CONTAINER_IDENTITY_PATH, 0o600)
|
||||
@@ -1429,7 +1439,8 @@ def gateway_status():
|
||||
"status": status,
|
||||
"message": message,
|
||||
**state_dir_status_payload(),
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"log_path": str(AUTH_DIR / "gateway.log"),
|
||||
"container_uuid": read_container_uuid(),
|
||||
"vault_path": str(VAULT_PATH),
|
||||
@@ -1610,7 +1621,7 @@ def clean_terminal_name(value, fallback="terminal"):
|
||||
|
||||
def default_gateway_session_name():
|
||||
source = AUTH.get("project_slug") or AUTH.get("project_name") or pathlib.Path.cwd().name or "project"
|
||||
return f"napseer-{clean_tmux_name(source, 'project')}"
|
||||
return f"napseer-{clean_tmux_name(source, 'project')}-{WORKSPACE.workspace_id[:10]}"
|
||||
|
||||
|
||||
def normalize_gateway_command(value=None):
|
||||
@@ -2175,7 +2186,7 @@ def write_gateway_schedules_state(state):
|
||||
state["schema"] = GATEWAY_SCHEDULES_SCHEMA
|
||||
state["version"] = 1
|
||||
state["updated_at"] = iso_now()
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
GATEWAY_SCHEDULES_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
chmod_best_effort(GATEWAY_SCHEDULES_PATH, 0o600)
|
||||
@@ -6567,7 +6578,7 @@ INDEX_SCHEMA_READY = {}
|
||||
|
||||
|
||||
def raw_index_file_lock(*, shared=False):
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
handle = INDEX_LOCK_PATH.open("a+b")
|
||||
chmod_best_effort(INDEX_LOCK_PATH, 0o600)
|
||||
@@ -6599,7 +6610,7 @@ def ensure_sqlite_column(conn, table, column, declaration):
|
||||
|
||||
|
||||
def open_index_connection():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
conn = sqlite3.connect(str(INDEX_PATH), timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@@ -8526,18 +8537,19 @@ def _refresh_public_auth_state_locked():
|
||||
|
||||
|
||||
def ensure_local_files():
|
||||
AUTH_DIR.mkdir(exist_ok=True)
|
||||
AUTH_DIR.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(AUTH_DIR, 0o700)
|
||||
ensure_project_locator_ignore_policy()
|
||||
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
|
||||
public_key_path = pathlib.Path(str(key_path) + ".pub")
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
with credential_lock(key_path):
|
||||
if not key_path.exists():
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
chmod_best_effort(key_path.parent, 0o700)
|
||||
subprocess.run(
|
||||
["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key_path)],
|
||||
check=True,
|
||||
)
|
||||
chmod_best_effort(key_path, 0o600)
|
||||
if public_key_path.exists():
|
||||
chmod_best_effort(public_key_path, 0o644)
|
||||
@@ -9223,11 +9235,12 @@ def cli_project_init(args):
|
||||
"status": "already_initialized",
|
||||
"message": (
|
||||
"This folder is already initialized. "
|
||||
f"Project '{existing_slug}' is configured at {AUTH_PATH}."
|
||||
f"Project '{existing_slug}' is configured at {project_locator_path()}."
|
||||
),
|
||||
"project_id": DEFAULT_PROJECT_ID,
|
||||
"project_slug": existing_slug,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": locator_result,
|
||||
}
|
||||
|
||||
@@ -9240,7 +9253,8 @@ def cli_project_init(args):
|
||||
),
|
||||
"project_id": locator["project_id"],
|
||||
"project_slug": locator["project_slug"],
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": project_locator_status(locator),
|
||||
"next": {"attach_project": "nap project attach"},
|
||||
}
|
||||
@@ -9280,7 +9294,8 @@ def cli_project_init(args):
|
||||
"to start a local gateway."
|
||||
),
|
||||
"steps": steps,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": project_locator_status(),
|
||||
}
|
||||
|
||||
@@ -9289,7 +9304,8 @@ def cli_project_init(args):
|
||||
"status": "initialized",
|
||||
"message": "Folder initialized; existing project kept.",
|
||||
"project_id": DEFAULT_PROJECT_ID,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_locator": project_locator_status(),
|
||||
"steps": steps,
|
||||
}
|
||||
@@ -9515,7 +9531,10 @@ def cli_gateway_service_call(route, payload=None):
|
||||
# session endpoint existed.
|
||||
session = cli_gateway_service_json("GET", f"{base_url}/local-api/state")
|
||||
service_auth_path = session.get("auth_path")
|
||||
allowed_auth_paths = {AUTH_PATH.resolve(), GATEWAY_AUTH_PATH.resolve()}
|
||||
service_state_dir = session.get("state_dir")
|
||||
if service_state_dir and pathlib.Path(service_state_dir).resolve() != AUTH_DIR.resolve():
|
||||
raise RuntimeError("running gateway belongs to a different worktree")
|
||||
allowed_auth_paths = {active_auth_path().resolve(), GATEWAY_AUTH_PATH.resolve()}
|
||||
if service_auth_path and pathlib.Path(service_auth_path).resolve() not in allowed_auth_paths:
|
||||
raise RuntimeError("running gateway belongs to a different project folder")
|
||||
csrf_token = session.get("csrf_token")
|
||||
@@ -14118,7 +14137,8 @@ def start_local_ui(args):
|
||||
"csrf_token": csrf_token,
|
||||
"agent_id": AUTH.get("agent_id"),
|
||||
"token_expires_at": TOKEN_EXPIRES_AT,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"account_mode": AUTH.get("account_mode"),
|
||||
"auth_configured": bool(TOKEN),
|
||||
"wrapper": {
|
||||
@@ -14211,7 +14231,8 @@ def start_local_ui(args):
|
||||
"csrf_token": csrf_token,
|
||||
"agent_id": AUTH.get("agent_id"),
|
||||
"token_expires_at": TOKEN_EXPIRES_AT,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"account_mode": AUTH.get("account_mode"),
|
||||
"auth_configured": bool(TOKEN),
|
||||
"wrapper": {
|
||||
@@ -14333,7 +14354,8 @@ def start_local_ui(args):
|
||||
if parsed_path.path == "/local-api/session":
|
||||
self.send_json(200, {
|
||||
"csrf_token": csrf_token,
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"project_id": current_project_id(),
|
||||
})
|
||||
return
|
||||
@@ -14358,7 +14380,7 @@ def start_local_ui(args):
|
||||
<html><head><meta charset="utf-8"><title>Napseer local manager</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>body{{font-family:ui-sans-serif,system-ui;margin:0;background:#eef3ef;color:#101312}}main{{max-width:720px;margin:0 auto;padding:28px}}section{{border:1px solid #d7ded8;background:#fbfdfb;padding:20px}}label{{display:grid;gap:6px;margin:12px 0;color:#5d6661;font-weight:700}}input,textarea,select{{font:inherit;border:1px solid #9aa9a0;background:#fff;padding:10px}}button{{border:1px solid #073f3b;background:#073f3b;color:#fff;font-weight:800;padding:10px 14px}}code{{background:#e5ece7;padding:2px 5px}}</style></head>
|
||||
<body><main><section><h1>Create this Napseer project</h1><p>This localhost UI is running before a project is configured for <code>{html.escape(str(AUTH_PATH))}</code>.</p>
|
||||
<body><main><section><h1>Create this Napseer project</h1><p>This localhost UI is running before a project is configured for <code>{html.escape(str(active_auth_path()))}</code>.</p>
|
||||
<form method="post" action="/bootstrap-project"><input type="hidden" name="csrf_token" value="{html.escape(csrf_token)}">
|
||||
<label>Project name<input name="name" required placeholder="{html.escape(default_project_name(default_project_slug()))}"></label>
|
||||
<label>Project slug<input name="slug" required value="{html.escape(default_project_slug())}"></label>
|
||||
@@ -14432,7 +14454,7 @@ def start_local_ui(args):
|
||||
</style></head>
|
||||
<body><main>
|
||||
<div class="topbar"><div class="brand"><span class="mark">N</span><span>Napseer local manager</span></div><nav><a href="#memory">Memory</a><a href="#schedules">Schedules</a><a href="#auth">Auth</a><a href="#feedback">Feedback</a><a href="#update">Update</a></nav></div>
|
||||
<section class="hero" id="overview"><h1>Project memory, local authority.</h1><p class="muted">This UI runs on <code>127.0.0.1</code>, reads <code>{html.escape(str(AUTH_PATH))}</code>, and publishes executable schedules with project auth.</p><div class="stats">
|
||||
<section class="hero" id="overview"><h1>Project memory, local authority.</h1><p class="muted">This UI runs on <code>127.0.0.1</code>, reads <code>{html.escape(str(active_auth_path()))}</code>, and publishes executable schedules with project auth.</p><div class="stats">
|
||||
<div class="stat"><b>Project</b><span class="mono">{html.escape(project_id)}</span></div>
|
||||
<div class="stat"><b>Agent</b><span class="mono">{html.escape(str(AUTH.get('agent_id') or 'unknown'))}</span></div>
|
||||
<div class="stat"><b>Token expires</b><span>{html.escape(str(TOKEN_EXPIRES_AT or 'unknown'))}</span></div>
|
||||
@@ -16971,6 +16993,8 @@ def safe_auth_refresh_error():
|
||||
|
||||
|
||||
def safe_exception_text(exc):
|
||||
if isinstance(exc, CredentialError):
|
||||
return str(exc) # CredentialError messages are fixed, secret-free diagnostics.
|
||||
if isinstance(exc, SafeToolError):
|
||||
return exc.safe_message
|
||||
return (
|
||||
@@ -17919,6 +17943,8 @@ def call_tool_impl(name, args):
|
||||
"Unknown or unavailable MCP tool. Use `nap_apropos` to discover the canonical surface.",
|
||||
)
|
||||
canonical_name = COMPATIBILITY_TOOL_ALIASES.get(name, name)
|
||||
if canonical_name not in {"nap_whoami", "nap_contract", "nap_man", "nap_apropos"}:
|
||||
WORKSPACE.require_migrated()
|
||||
if canonical_name not in enabled_tool_names():
|
||||
profile = tool_profile(canonical_name)
|
||||
raise SafeToolError(
|
||||
@@ -18250,7 +18276,8 @@ def handle(message):
|
||||
"serverInfo": {"name": "napseer-local", "version": "0.1.6"},
|
||||
"instructions": "Local authenticated MCP wrapper. It resolves user or project credentials and repository identity; agents should not handle secrets or pass project ids for normal tools.",
|
||||
"napseer": {
|
||||
"auth_path": str(AUTH_PATH),
|
||||
"auth_path": str(active_auth_path()),
|
||||
"state_dir": str(AUTH_DIR),
|
||||
"base_url": BASE_URL,
|
||||
"has_token": bool(TOKEN),
|
||||
"project_id": DEFAULT_PROJECT_ID,
|
||||
@@ -18390,6 +18417,9 @@ class McpRequestRuntime:
|
||||
|
||||
def cli_main(argv):
|
||||
command = argv[1] if len(argv) > 1 else ""
|
||||
recovery = argv[1:] in (["auth", "migrate"], ["auth", "status"], ["project", "status"])
|
||||
if command and not recovery and not any(arg in {"help", "--help", "-h"} for arg in argv[1:]) and command not in {"configure", "config", "update", "update-status"}:
|
||||
WORKSPACE.require_migrated()
|
||||
if command == "reindex":
|
||||
args = argv[2:]
|
||||
validate_cli_options(
|
||||
@@ -18452,7 +18482,7 @@ def cli_main(argv):
|
||||
nap auth logout [--project]
|
||||
nap auth api-key --env VARIABLE_NAME
|
||||
nap auth keys list|create|revoke
|
||||
nap auth migrate Move legacy credentials to user data without broadening scope.
|
||||
nap auth migrate Move legacy private state to user data; stop older clients/gateways first.
|
||||
nap auth use-user Explicitly use the general login in this repository.
|
||||
|
||||
`login` signs in to the account only. It does not select a project.
|
||||
@@ -18462,7 +18492,10 @@ Refresh is automatic during authenticated calls; use `repair` only for recovery.
|
||||
return
|
||||
if subcommand in {"use-user","migrate"}:
|
||||
validate_cli_options(args, command_label=f"nap auth {subcommand}")
|
||||
path = credential_store().use_user() if subcommand == "use-user" else credential_store().migrate()
|
||||
if subcommand == "migrate":
|
||||
print(json.dumps(WORKSPACE.migrate(), indent=2))
|
||||
return
|
||||
path = credential_store().use_user()
|
||||
print(json.dumps({"status":"configured","auth_path":str(path),"message":"Credential selection updated. Existing project identity is preserved."},indent=2)); return
|
||||
if subcommand in {"login", "login-local", "operator-login"}:
|
||||
validate_cli_options(
|
||||
|
||||
Reference in New Issue
Block a user