feat: add portable project locator

This commit is contained in:
HumbleTurtle
2026-08-27 19:11:33 -03:00
parent 7eace14042
commit f8594bd3d8
8 changed files with 544 additions and 12 deletions
+11
View File
@@ -28,6 +28,10 @@ Current state:
- Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local - Operator OAuth credentials remain in `.napseer/auth.json`. A repaired local
gateway uses a separate `.napseer/gateway-auth.json` worker identity, so gateway uses a separate `.napseer/gateway-auth.json` worker identity, so
relay token renewal cannot rotate or overwrite the operator/MCP session. relay token renewal cannot rotate or overwrite the operator/MCP session.
- `.napseer/project.json` is the commit-safe project locator. It contains only
the schema, API origin, project UUID, and slug; it never contains tokens,
account or worker identity, encryption state, keys, passphrases, or claim
links. A fresh clone uses it to attach the intended project.
- CLI terminal and schedule operations call the running gateway's - CLI terminal and schedule operations call the running gateway's
loopback-only, CSRF-protected control API. PTYs therefore live in the daemon loopback-only, CSRF-protected control API. PTYs therefore live in the daemon
instead of disappearing when a CLI subprocess exits. instead of disappearing when a CLI subprocess exits.
@@ -71,6 +75,13 @@ after the anonymous account reaches its service limit (currently one project
or 100 stored nodes), or when its durable local enrollment identity is no or 100 stored nodes), or when its durable local enrollment identity is no
longer recoverable. longer recoverable.
A cloned repository that already commits `.napseer/project.json` is not a new
workspace: `nap init` and project-scoped MCP tools fail closed instead of
creating a duplicate anonymous project. Run `nap project attach`; the OAuth
approval is constrained to the locator's project after normal server-side
access validation. If that project is still anonymous, claim it from a machine
that retains its enrollment identity, then attach from the new computer.
In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading In Bash, run commands as `nap update`, `nap auth login`, and so on. A leading
`!` is shell history expansion: `!nap update` replays the most recent command `!` is shell history expansion: `!nap update` replays the most recent command
whose text starts with `nap` and appends `update`; it does not invoke an whose text starts with `nap` and appends `update`; it does not invoke an
+339 -12
View File
@@ -318,6 +318,9 @@ def send_telemetry_event_async(event, component, outcome="success", **fields):
AUTH_DIR = local_state_dir() AUTH_DIR = local_state_dir()
AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve() AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", AUTH_DIR / "auth.json")).expanduser().resolve()
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
PROJECT_LOCATOR_FILENAME = "project.json"
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json" GATEWAY_AUTH_PATH = AUTH_DIR / "gateway-auth.json"
VAULT_PATH = AUTH_DIR / "vault.json" VAULT_PATH = AUTH_DIR / "vault.json"
LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key" LOCAL_VAULT_KEY_PATH = AUTH_DIR / "vault.local-key"
@@ -857,6 +860,210 @@ def write_public_auth(data):
atomic_write_auth_file(public) atomic_write_auth_file(public)
def project_locator_path():
"""Return the repository-visible project locator beside private auth state."""
return AUTH_DIR / PROJECT_LOCATOR_FILENAME
def validate_project_locator(data):
if not isinstance(data, dict):
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json must contain one JSON object.",
)
if set(data) != PROJECT_LOCATOR_FIELDS:
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.",
)
if data.get("schema") != PROJECT_LOCATOR_SCHEMA:
raise SafeToolError(
"project_locator_schema_unsupported",
".napseer/project.json uses an unsupported schema. Update nap before attaching this repository.",
)
raw_base_url = str(data.get("base_url") or "").strip().rstrip("/")
parsed = urllib.parse.urlparse(raw_base_url)
loopback = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
if (
parsed.scheme not in ({"https"} if not loopback else {"http", "https"})
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.params
or parsed.query
or parsed.fragment
):
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json base_url must be an HTTPS origin (HTTP is allowed only for loopback development).",
)
try:
project_id = str(uuid.UUID(str(data.get("project_id") or "")))
except (AttributeError, TypeError, ValueError) as exc:
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json project_id must be a UUID.",
) from exc
project_slug = str(data.get("project_slug") or "").strip().lower()
if (
not project_slug
or len(project_slug) > 64
or not re.fullmatch(r"[a-z0-9._]+(?:-[a-z0-9._]+)*", project_slug)
):
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json project_slug is invalid.",
)
return {
"schema": PROJECT_LOCATOR_SCHEMA,
"base_url": raw_base_url,
"project_id": project_id,
"project_slug": project_slug,
}
def load_project_locator():
path = project_locator_path()
if not path.exists():
return None
if path.is_symlink():
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json must be a regular file, not a symlink.",
)
try:
if path.stat().st_size > 4096:
raise ValueError("project locator is too large")
data = json.loads(path.read_text(encoding="utf-8"))
except SafeToolError:
raise
except Exception as exc:
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json could not be read as bounded JSON.",
) from exc
return validate_project_locator(data)
def require_project_locator_service(locator):
if locator and locator["base_url"] != str(BASE_URL or "").rstrip("/"):
raise SafeToolError(
"project_locator_service_mismatch",
"This repository names a different Napseer service. Verify the repository, then set NAPSEER_BASE_URL explicitly before attaching.",
)
def project_locator_status(locator=None):
locator = load_project_locator() if locator is None else locator
if not locator:
return {
"declared": False,
"path": str(project_locator_path()),
"matches_local_auth": None,
}
configured_project_id = str(DEFAULT_PROJECT_ID or "")
return {
"declared": True,
"path": str(project_locator_path()),
**locator,
"matches_local_auth": (
locator["project_id"] == configured_project_id
if configured_project_id
else None
),
}
def ensure_project_locator_ignore_policy():
state_dir = project_locator_path().parent
if state_dir.is_symlink():
raise SafeToolError(
"project_locator_invalid",
"The repository .napseer directory must not be a symlink when writing project identity.",
)
state_dir.mkdir(parents=True, exist_ok=True)
nested_ignore = state_dir / ".gitignore"
if nested_ignore.is_symlink():
raise SafeToolError(
"project_locator_invalid",
".napseer/.gitignore must be a regular file, not a symlink.",
)
nested_lines = []
if nested_ignore.exists():
nested_lines = nested_ignore.read_text(encoding="utf-8").splitlines()
if "*" not in nested_lines:
nested_lines.insert(0, "*")
for exception in ("!.gitignore", f"!{PROJECT_LOCATOR_FILENAME}"):
if exception not in nested_lines:
nested_lines.append(exception)
nested_ignore.write_text("\n".join(nested_lines) + "\n", encoding="utf-8")
repository_root = state_dir.parent
root_ignore = repository_root / ".gitignore"
if root_ignore.is_symlink():
raise SafeToolError(
"project_locator_invalid",
"The repository .gitignore must be a regular file, not a symlink.",
)
root_lines = root_ignore.read_text(encoding="utf-8").splitlines() if root_ignore.exists() else []
root_lines = [line for line in root_lines if line not in {"/.napseer/", ".napseer/"}]
managed_lines = (
"/.napseer/*",
"!/.napseer/.gitignore",
f"!/.napseer/{PROJECT_LOCATOR_FILENAME}",
)
for line in managed_lines:
if line not in root_lines:
root_lines.append(line)
root_ignore.write_text("\n".join(root_lines) + "\n", encoding="utf-8")
def write_project_locator(project, *, base_url=None):
project = project if isinstance(project, dict) else {}
desired = validate_project_locator(
{
"schema": PROJECT_LOCATOR_SCHEMA,
"base_url": base_url or BASE_URL,
"project_id": project.get("id") or project.get("project_id"),
"project_slug": project.get("slug") or project.get("project_slug"),
}
)
existing = load_project_locator()
if existing and (
existing["base_url"] != desired["base_url"]
or existing["project_id"] != desired["project_id"]
):
raise SafeToolError(
"project_locator_mismatch",
"Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.",
)
ensure_project_locator_ignore_policy()
path = project_locator_path()
if path.is_symlink():
raise SafeToolError(
"project_locator_invalid",
".napseer/project.json must be a regular file, not a symlink.",
)
temporary = path.with_name(f".{path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
try:
temporary.write_text(json.dumps(desired, indent=2) + "\n", encoding="utf-8")
chmod_best_effort(temporary, 0o644)
os.replace(temporary, path)
chmod_best_effort(path, 0o644)
finally:
try:
temporary.unlink()
except FileNotFoundError:
pass
return project_locator_status(desired)
def read_vault(passphrase, return_key=False): def read_vault(passphrase, return_key=False):
envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8")) envelope = json.loads(VAULT_PATH.read_text(encoding="utf-8"))
if envelope.get("schema") != VAULT_SCHEMA: if envelope.get("schema") != VAULT_SCHEMA:
@@ -1200,10 +1407,21 @@ def gateway_lock():
def gateway_status(): def gateway_status():
locator = load_project_locator()
require_project_locator_service(locator)
unlocked = gateway_is_unlocked() unlocked = gateway_is_unlocked()
remote_enabled = gateway_remote_enabled_value(unlocked) remote_enabled = gateway_remote_enabled_value(unlocked)
listener_ready = bool(remote_enabled and GATEWAY_LISTENER_CONNECTED) listener_ready = bool(remote_enabled and GATEWAY_LISTENER_CONNECTED)
if not DEFAULT_PROJECT_ID or not TOKEN: locator_mismatch = bool(
locator and DEFAULT_PROJECT_ID and str(DEFAULT_PROJECT_ID) != locator["project_id"]
)
if locator_mismatch:
status = "project_locator_mismatch"
message = "Local project credentials do not match the committed project locator."
elif (not DEFAULT_PROJECT_ID or not TOKEN) and locator:
status = "project_access_required"
message = "This repository declares a project but needs local access; run nap project attach."
elif not DEFAULT_PROJECT_ID or not TOKEN:
status = "not_configured" status = "not_configured"
message = "No project/token is configured for this directory." message = "No project/token is configured for this directory."
elif not vault_exists(): elif not vault_exists():
@@ -1237,6 +1455,7 @@ def gateway_status():
"project_slug": AUTH.get("project_slug"), "project_slug": AUTH.get("project_slug"),
"project_name": AUTH.get("project_name"), "project_name": AUTH.get("project_name"),
"project_configured": bool(DEFAULT_PROJECT_ID), "project_configured": bool(DEFAULT_PROJECT_ID),
"project_locator": project_locator_status(locator),
"agent_id": AUTH.get("agent_id"), "agent_id": AUTH.get("agent_id"),
"worker_id": AUTH.get("worker_id"), "worker_id": AUTH.get("worker_id"),
"token_expires_at": TOKEN_EXPIRES_AT, "token_expires_at": TOKEN_EXPIRES_AT,
@@ -5542,7 +5761,9 @@ Subcommands:
attach Attach this directory to an existing attach Attach this directory to an existing
[--no-browser] project you own. Opens a browser for the [--no-browser] project you own. Opens a browser for the
[--timeout SECONDS] Keycloak loopback login, then lets you pick [--timeout SECONDS] Keycloak loopback login, then lets you pick
the project from a list. the project from a list. If this repository
commits .napseer/project.json, attachment is
constrained to that exact project.
claim Claim an anonymous project (or link this claim Claim an anonymous project (or link this
[--no-browser] directory to your operator account) via [--no-browser] directory to your operator account) via
@@ -5564,9 +5785,11 @@ Subcommands:
First-time flow: First-time flow:
1. From a fresh project directory, run `nap project init`. 1. From a fresh project directory, run `nap project init`.
The CLI will create a new project for you and write the The CLI will create a new project for you and write the
worker bearer to ./.napseer/auth.json. worker bearer to ./.napseer/auth.json. It also writes the
non-secret ./.napseer/project.json locator for Git.
2. To attach a directory to a project you already own, run 2. To attach a directory to a project you already own, run
`nap project attach` and sign in via the browser. `nap project attach` and sign in via the browser. A cloned
repository with a locator will never auto-create a duplicate.
3. To check what is attached, run `nap project status`. 3. To check what is attached, run `nap project status`.
""" """
@@ -7898,6 +8121,8 @@ def oauth_loopback_authorize(args):
} }
if args.get("claim_token"): if args.get("claim_token"):
authorization_payload["claim_token"] = args["claim_token"] authorization_payload["claim_token"] = args["claim_token"]
if args.get("project_id_hint"):
authorization_payload["project_id_hint"] = args["project_id_hint"]
authorization = request_json( authorization = request_json(
"POST", "POST",
"/v1/oauth/native/authorizations", "/v1/oauth/native/authorizations",
@@ -8000,14 +8225,22 @@ def operator_account_login(args=None):
def operator_project_attach(args=None): def operator_project_attach(args=None):
args = args or {} args = args or {}
locator = load_project_locator()
require_project_locator_service(locator)
result = oauth_loopback_authorize({ result = oauth_loopback_authorize({
**args, **args,
"flow": "local_operator_access", "flow": "local_operator_access",
"scope": args.get("scope") or LOCAL_PROJECT_OAUTH_SCOPE, "scope": args.get("scope") or LOCAL_PROJECT_OAUTH_SCOPE,
"project_id_hint": locator["project_id"] if locator else None,
}) })
project_id = result.get("project_id") project_id = result.get("project_id")
if not project_id: if not project_id:
raise RuntimeError("OAuth authorization did not return a selected project_id") raise RuntimeError("OAuth authorization did not return a selected project_id")
if locator and str(project_id) != locator["project_id"]:
raise SafeToolError(
"project_locator_mismatch",
"OAuth selected a different project than the committed .napseer/project.json locator; local credentials were not changed.",
)
token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0)) token_expires_at = iso_timestamp_after_seconds(int(result.get("expires_in") or 0))
replace_public_auth_state( replace_public_auth_state(
{ {
@@ -8032,6 +8265,13 @@ def operator_project_attach(args=None):
}) })
except Exception: except Exception:
project = None project = None
if project:
write_project_locator(project, base_url=result.get("api_base_url") or BASE_URL)
elif not locator:
raise SafeToolError(
"project_locator_sync_failed",
"Project access was stored, but public project metadata could not be fetched for .napseer/project.json. Retry `nap project init` when the service is reachable.",
)
return { return {
"status": "attached", "status": "attached",
"mode": "operator_project", "mode": "operator_project",
@@ -8039,6 +8279,7 @@ def operator_project_attach(args=None):
"base_url": result.get("api_base_url") or BASE_URL, "base_url": result.get("api_base_url") or BASE_URL,
"project_id": project_id, "project_id": project_id,
"project": project, "project": project,
"project_locator": project_locator_status(locator if project is None else None),
"scope": result.get("scope"), "scope": result.get("scope"),
"token_expires_at": token_expires_at, "token_expires_at": token_expires_at,
"message": "OAuth2 PKCE project token and selected project stored in this directory's Napseer auth state.", "message": "OAuth2 PKCE project token and selected project stored in this directory's Napseer auth state.",
@@ -8116,9 +8357,7 @@ def _refresh_public_auth_state_locked():
def ensure_local_files(): def ensure_local_files():
AUTH_DIR.mkdir(exist_ok=True) AUTH_DIR.mkdir(exist_ok=True)
chmod_best_effort(AUTH_DIR, 0o700) chmod_best_effort(AUTH_DIR, 0o700)
gitignore = AUTH_DIR / ".gitignore" ensure_project_locator_ignore_policy()
if not gitignore.exists():
gitignore.write_text("*\n!.gitignore\n", encoding="utf-8")
key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser() key_path = pathlib.Path(AUTH["ssh_key_path"] or DEFAULT_KEY_PATH).expanduser()
public_key_path = pathlib.Path(str(key_path) + ".pub") public_key_path = pathlib.Path(str(key_path) + ".pub")
if not key_path.exists(): if not key_path.exists():
@@ -8490,7 +8729,19 @@ def resolve_project_id(args):
if AUTH_PATH.exists() or not DEFAULT_PROJECT_ID: if AUTH_PATH.exists() or not DEFAULT_PROJECT_ID:
refresh_public_auth_state() refresh_public_auth_state()
project_id = DEFAULT_PROJECT_ID project_id = DEFAULT_PROJECT_ID
locator = load_project_locator()
require_project_locator_service(locator)
if project_id and locator and str(project_id) != locator["project_id"]:
raise SafeToolError(
"project_locator_mismatch",
"Local project credentials do not match the committed .napseer/project.json locator. Run `nap project status` and attach the declared project.",
)
if not project_id: if not project_id:
if locator:
raise SafeToolError(
"project_access_required",
"This repository already declares a Napseer project and will not create a duplicate anonymous project. Run `nap project attach`; if the project is still anonymous, claim it from a machine that retains its enrollment identity, then retry here.",
)
slug = default_project_slug() slug = default_project_slug()
try: try:
initialized = bootstrap_project( initialized = bootstrap_project(
@@ -8570,6 +8821,7 @@ def create_project_with_state(args):
if AUTH.get("account_mode") == "operator_account": if AUTH.get("account_mode") == "operator_account":
auth_updates["account_mode"] = "operator_project" auth_updates["account_mode"] = "operator_project"
save_auth(auth_updates) save_auth(auth_updates)
write_project_locator(project)
try: try:
register_project_signing_key({"label": "local-project-key"}) register_project_signing_key({"label": "local-project-key"})
except Exception: except Exception:
@@ -8764,6 +9016,13 @@ def looks_like_service_bootstrap_token(value):
def cli_project_create(args): def cli_project_create(args):
locator = load_project_locator()
require_project_locator_service(locator)
if locator:
raise SafeToolError(
"project_locator_exists",
"This repository already declares a Napseer project in .napseer/project.json. Run `nap project attach` instead of creating a second project; update the committed locator intentionally if this repository is being rebound.",
)
positionals = cli_positionals(args) positionals = cli_positionals(args)
slug = cli_option(args, "--slug", default=positionals[0] if positionals else default_project_slug()) slug = cli_option(args, "--slug", default=positionals[0] if positionals else default_project_slug())
name = cli_option(args, "--name", default=default_project_name(slug)) name = cli_option(args, "--name", default=default_project_name(slug))
@@ -8795,10 +9054,37 @@ def cli_project_init(args):
slug = cli_option(args, "--slug", default=positionals[0] if positionals else default_project_slug()) slug = cli_option(args, "--slug", default=positionals[0] if positionals else default_project_slug())
name = cli_option(args, "--name", default=default_project_name(slug)) name = cli_option(args, "--name", default=default_project_name(slug))
description = cli_option(args, "--description", default="") description = cli_option(args, "--description", default="")
locator = load_project_locator()
require_project_locator_service(locator)
already_initialized = bool(TOKEN) and bool(DEFAULT_PROJECT_ID) already_initialized = bool(TOKEN) and bool(DEFAULT_PROJECT_ID)
if already_initialized: if already_initialized:
existing_slug = load_public_auth_file().get("project_slug") or "" if locator and str(DEFAULT_PROJECT_ID) != locator["project_id"]:
raise SafeToolError(
"project_locator_mismatch",
"Local project credentials do not match the committed .napseer/project.json locator. Run `nap project status` and attach the declared project.",
)
existing_slug = load_public_auth_file().get("project_slug") or (locator or {}).get("project_slug") or ""
if not existing_slug:
try:
project = request_json("GET", f"/v1/projects/{DEFAULT_PROJECT_ID}")
existing_slug = str(project.get("slug") or "")
save_auth_file_credentials(
{
"project_slug": existing_slug,
"project_name": project.get("name"),
"project_encryption_state": project.get("encryption_state"),
}
)
except Exception as exc:
raise SafeToolError(
"project_locator_sync_failed",
"The attached project is missing public slug metadata needed for .napseer/project.json. Retry `nap project init` when the service is reachable.",
) from exc
locator_result = write_project_locator(
{"id": DEFAULT_PROJECT_ID, "slug": existing_slug},
base_url=(locator or {}).get("base_url") or BASE_URL,
)
return { return {
"status": "already_initialized", "status": "already_initialized",
"message": ( "message": (
@@ -8808,6 +9094,21 @@ def cli_project_init(args):
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
"project_slug": existing_slug, "project_slug": existing_slug,
"auth_path": str(AUTH_PATH), "auth_path": str(AUTH_PATH),
"project_locator": locator_result,
}
if locator:
return {
"status": "project_access_required",
"message": (
"This repository already declares a Napseer project and will not create a duplicate anonymous project. "
"Run `nap project attach`. If the project is still anonymous, claim it from a machine that retains its enrollment identity, then retry here."
),
"project_id": locator["project_id"],
"project_slug": locator["project_slug"],
"auth_path": str(AUTH_PATH),
"project_locator": project_locator_status(locator),
"next": {"attach_project": "nap project attach"},
} }
steps = [] steps = []
@@ -8847,6 +9148,7 @@ def cli_project_init(args):
), ),
"steps": steps, "steps": steps,
"auth_path": str(AUTH_PATH), "auth_path": str(AUTH_PATH),
"project_locator": project_locator_status(),
} }
steps.append({"step": "create_project", "status": "skipped", "reason": "project already configured"}) steps.append({"step": "create_project", "status": "skipped", "reason": "project already configured"})
@@ -8855,6 +9157,7 @@ def cli_project_init(args):
"message": "Folder initialized; existing project kept.", "message": "Folder initialized; existing project kept.",
"project_id": DEFAULT_PROJECT_ID, "project_id": DEFAULT_PROJECT_ID,
"auth_path": str(AUTH_PATH), "auth_path": str(AUTH_PATH),
"project_locator": project_locator_status(),
"steps": steps, "steps": steps,
} }
@@ -9110,10 +9413,15 @@ def cli_project_claim(args):
def cli_configure_status(): def cli_configure_status():
locator = load_project_locator()
require_project_locator_service(locator)
project_configured = bool(DEFAULT_PROJECT_ID) project_configured = bool(DEFAULT_PROJECT_ID)
locator_mismatch = bool(
locator and project_configured and str(DEFAULT_PROJECT_ID) != locator["project_id"]
)
account_only = AUTH.get("account_mode") == "operator_account" and not project_configured account_only = AUTH.get("account_mode") == "operator_account" and not project_configured
encryption = {"state": "plaintext", "updated_at": ""} encryption = {"state": "plaintext", "updated_at": ""}
if project_configured: if project_configured and not locator_mismatch:
try: try:
refresh_current_project_metadata() refresh_current_project_metadata()
encryption = project_encryption_status({}) encryption = project_encryption_status({})
@@ -9128,10 +9436,22 @@ def cli_configure_status():
else: else:
encryption = {"state": AUTH.get("project_encryption_state") or "unknown", "updated_at": ""} encryption = {"state": AUTH.get("project_encryption_state") or "unknown", "updated_at": ""}
return { return {
"status": "configured" if project_configured else "not_configured", "status": (
"message": ( "project_locator_mismatch"
"Project is configured for this directory." if locator_mismatch
else "configured"
if project_configured if project_configured
else "project_access_required"
if locator
else "not_configured"
),
"message": (
"Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project before continuing."
if locator_mismatch
else "Project is configured for this directory."
if project_configured
else "This repository declares a Napseer project but has no local credentials. Run `nap project attach`; request access or claim the anonymous project from its enrolled machine if needed."
if locator
else ( else (
"Account authenticated; no project is attached. Run `nap project attach` " "Account authenticated; no project is attached. Run `nap project attach` "
"for an existing project or `nap project create` for a new one." "for an existing project or `nap project create` for a new one."
@@ -9147,6 +9467,7 @@ def cli_configure_status():
"project_configured": project_configured, "project_configured": project_configured,
"project_id": DEFAULT_PROJECT_ID or "", "project_id": DEFAULT_PROJECT_ID or "",
"project_slug": load_public_auth_file().get("project_slug", ""), "project_slug": load_public_auth_file().get("project_slug", ""),
"project_locator": project_locator_status(locator),
"encryption_state": encryption.get("state") or "unknown", "encryption_state": encryption.get("state") or "unknown",
"encryption_updated_at": encryption.get("updated_at") or "", "encryption_updated_at": encryption.get("updated_at") or "",
"token_expires_at": TOKEN_EXPIRES_AT, "token_expires_at": TOKEN_EXPIRES_AT,
@@ -13394,6 +13715,12 @@ def claim_account(args):
"claimed_at": iso_now(), "claimed_at": iso_now(),
} }
) )
current = load_public_auth_file()
if current.get("project_id") and current.get("project_slug"):
write_project_locator(
{"id": current["project_id"], "slug": current["project_slug"]},
base_url=current.get("base_url") or BASE_URL,
)
return { return {
"claim_url": claim["claim_url"], "claim_url": claim["claim_url"],
"expires_at": claim["expires_at"], "expires_at": claim["expires_at"],
+2
View File
@@ -15,6 +15,8 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
return module return module
+4
View File
@@ -17,6 +17,10 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
module.write_project_locator = lambda *_args, **_kwargs: {"declared": False}
module.project_locator_status = lambda _locator=None: {"declared": False}
return module return module
+3
View File
@@ -88,12 +88,14 @@ def test_successful_create_transitions_local_state_to_project_mode():
mod = load_module() mod = load_module()
configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE) configure_account_state(mod, mod.OPERATOR_ACCOUNT_OAUTH_SCOPE)
saves = [] saves = []
locators = []
mod.request_json = lambda method, path, payload=None: { mod.request_json = lambda method, path, payload=None: {
"id": PROJECT_ID, "id": PROJECT_ID,
"slug": payload["slug"], "slug": payload["slug"],
"name": payload["name"], "name": payload["name"],
} }
mod.save_auth = lambda updates: saves.append(dict(updates)) mod.save_auth = lambda updates: saves.append(dict(updates))
mod.write_project_locator = lambda project: locators.append(dict(project))
project, status, _message = mod.create_project_with_state(project_args()) project, status, _message = mod.create_project_with_state(project_args())
@@ -101,6 +103,7 @@ def test_successful_create_transitions_local_state_to_project_mode():
assert project["id"] == PROJECT_ID assert project["id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID assert saves[0]["project_id"] == PROJECT_ID
assert saves[0]["account_mode"] == "operator_project" assert saves[0]["account_mode"] == "operator_project"
assert locators == [project]
def test_server_scope_denial_is_actionable(): def test_server_scope_denial_is_actionable():
+4
View File
@@ -16,6 +16,10 @@ def load_module():
module = importlib.util.module_from_spec(spec) module = importlib.util.module_from_spec(spec)
assert spec and spec.loader assert spec and spec.loader
spec.loader.exec_module(module) spec.loader.exec_module(module)
module.load_project_locator = lambda: None
module.require_project_locator_service = lambda _locator: None
module.write_project_locator = lambda *_args, **_kwargs: {"declared": False}
module.project_locator_status = lambda _locator=None: {"declared": False}
return module return module
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Contract tests for the committed, non-secret Napseer project locator."""
import importlib.util
import json
import pathlib
import subprocess
import sys
import tempfile
PROJECT_ID = "33333333-3333-3333-3333-333333333333"
OTHER_PROJECT_ID = "44444444-4444-4444-4444-444444444444"
def load_module():
script_path = pathlib.Path(__file__).resolve().parents[1] / "resources" / "scripts" / "napseer_mcp_server.py"
sys.path.insert(0, str(script_path.parent))
spec = importlib.util.spec_from_file_location("napseer_mcp_server_project_locator_test", script_path)
module = importlib.util.module_from_spec(spec)
assert spec and spec.loader
spec.loader.exec_module(module)
return module
def configure_state(mod, root):
state_dir = pathlib.Path(root) / ".napseer"
mod.AUTH_DIR = state_dir
mod.AUTH_PATH = state_dir / "auth.json"
mod.BASE_URL = "https://api.example.test"
mod.TOKEN = None
mod.DEFAULT_PROJECT_ID = None
mod.AUTH = {}
return state_dir
def test_locator_is_deterministic_committable_and_secret_free():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
state_dir = configure_state(mod, root)
mod.DEFAULT_PROJECT_ID = PROJECT_ID
result = mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
locator = json.loads((state_dir / "project.json").read_text(encoding="utf-8"))
assert locator == {
"schema": "napseer.project.v1",
"base_url": "https://api.example.test",
"project_id": PROJECT_ID,
"project_slug": "portable-project",
}
assert result["declared"] is True
assert result["matches_local_auth"] is True
forbidden = {
"token", "access_token", "refresh_token", "account_id", "agent_id",
"worker_id", "claim_token", "claim_url", "passphrase", "encryption_state",
}
assert forbidden.isdisjoint(locator)
(state_dir / "auth.json").write_text('{"token":"private"}\n', encoding="utf-8")
(state_dir / "gateway-auth.json").write_text('{"token":"private"}\n', encoding="utf-8")
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/auth.json"], cwd=root
).returncode == 0
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/gateway-auth.json"], cwd=root
).returncode == 0
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/project.json"], cwd=root
).returncode == 1
assert subprocess.run(
["git", "check-ignore", "--quiet", ".napseer/.gitignore"], cwd=root
).returncode == 1
def test_locator_rejects_extra_secret_fields_and_mismatched_auth():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
state_dir = configure_state(mod, directory)
state_dir.mkdir(parents=True)
(state_dir / "project.json").write_text(
json.dumps({
"schema": "napseer.project.v1",
"base_url": "https://api.example.test",
"project_id": PROJECT_ID,
"project_slug": "portable-project",
"token": "must-not-be-accepted",
}),
encoding="utf-8",
)
try:
mod.load_project_locator()
except mod.SafeToolError as exc:
assert exc.code == "project_locator_invalid"
else:
raise AssertionError("locator with an extra secret field must be rejected")
(state_dir / "project.json").unlink()
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
mod.DEFAULT_PROJECT_ID = OTHER_PROJECT_ID
mod.refresh_public_auth_state = lambda: None
try:
mod.resolve_project_id({})
except mod.SafeToolError as exc:
assert exc.code == "project_locator_mismatch"
else:
raise AssertionError("local auth/locator mismatch must fail closed")
def test_fresh_clone_requires_attach_and_never_bootstraps_anonymous_project():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
configure_state(mod, directory)
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
enrollment_calls = []
bootstrap_calls = []
mod.ensure_enrolled = lambda args: enrollment_calls.append(dict(args))
mod.bootstrap_project = lambda args: bootstrap_calls.append(dict(args))
result = mod.cli_project_init([])
assert result["status"] == "project_access_required"
assert result["project_id"] == PROJECT_ID
assert result["next"]["attach_project"] == "nap project attach"
assert enrollment_calls == []
assert bootstrap_calls == []
mod.refresh_public_auth_state = lambda: None
try:
mod.resolve_project_id({})
except mod.SafeToolError as exc:
assert exc.code == "project_access_required"
assert "will not create a duplicate anonymous project" in exc.safe_message
else:
raise AssertionError("first MCP project resolution must require attach")
def test_attach_forwards_and_verifies_repository_project_hint():
mod = load_module()
with tempfile.TemporaryDirectory() as directory:
configure_state(mod, directory)
mod.write_project_locator({"id": PROJECT_ID, "slug": "portable-project"})
oauth_calls = []
saves = []
def oauth(args):
oauth_calls.append(dict(args))
return {
"api_base_url": "https://api.example.test",
"access_token": "local-test-token",
"refresh_token": "local-test-refresh",
"expires_in": 3600,
"scope": args["scope"],
"project_id": PROJECT_ID,
}
mod.oauth_loopback_authorize = oauth
mod.replace_public_auth_state = lambda updates, clear_keys=(): saves.append(dict(updates))
mod.save_auth_file_credentials = lambda updates: saves.append(dict(updates))
mod.request_json = lambda method, path: {
"id": PROJECT_ID,
"slug": "portable-project",
"name": "Portable Project",
"encryption_state": "standard",
}
result = mod.operator_project_attach({"open_browser": False})
assert oauth_calls[0]["project_id_hint"] == PROJECT_ID
assert result["project_id"] == PROJECT_ID
assert result["project_locator"]["project_id"] == PROJECT_ID
assert saves[0]["project_id"] == PROJECT_ID
if __name__ == "__main__":
test_locator_is_deterministic_committable_and_secret_free()
test_locator_rejects_extra_secret_fields_and_mismatched_auth()
test_fresh_clone_requires_attach_and_never_bootstraps_anonymous_project()
test_attach_forwards_and_verifies_repository_project_hint()
print("ok: committed project locator contract passed")
+1
View File
@@ -23,6 +23,7 @@ SMOKES = [
"test_project_attach_oauth.py", "test_project_attach_oauth.py",
"test_project_encryption_enable_payload.py", "test_project_encryption_enable_payload.py",
"test_project_init_wizard.py", "test_project_init_wizard.py",
"test_project_locator.py",
"test_two_passphrase_boundary.py", "test_two_passphrase_boundary.py",
] ]