Move tool profiles to a root file and version the release as 0.5.0
- Tool profiles come from a committed `.napseer-tools.json` at the
project root (schema napseer.tools.v1). The unreleased tool_profiles
field in .napseer/project.json is removed: released clients reject
unknown locator fields, and 0.4.0 treats any extra file inside .napseer/
as private state needing migration (a test loads the released 0.4.0
check from c427e24 to prove the root file is invisible to it).
Environment still overrides; unusable files warn and fall back to core.
- CLI release 0.5.0 with distribution contract 2026-09-29; the minimum
supported contract stays 2026-09-10. The newer-contract rejection test
now derives its date from the installer's own contract.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d86610da8e
commit
611a4571f3
@@ -66,8 +66,8 @@ RUNTIME_SCRIPT_NAMES = (
|
|||||||
"napseer_mcp_supervisor.py",
|
"napseer_mcp_supervisor.py",
|
||||||
)
|
)
|
||||||
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
|
MAX_SERVICE_LOG_BYTES = int(os.environ.get("NAPSEER_MAX_SERVICE_LOG_BYTES", str(5 * 1024 * 1024)))
|
||||||
CLI_RELEASE_VERSION = "0.4.0"
|
CLI_RELEASE_VERSION = "0.5.0"
|
||||||
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-10"
|
CLI_DISTRIBUTION_CONTRACT_VERSION = "2026-09-29"
|
||||||
# Older installers have a fixed asset list and would omit the credentials module.
|
# Older installers have a fixed asset list and would omit the credentials module.
|
||||||
# Reject them before activation; the published bootstrap performs the upgrade.
|
# Reject them before activation; the published bootstrap performs the upgrade.
|
||||||
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
|
CLI_MINIMUM_CONTRACT_VERSION = "2026-09-10"
|
||||||
|
|||||||
@@ -326,9 +326,12 @@ AUTH_PATH = pathlib.Path(os.environ.get("NAPSEER_AUTH_FILE", PROJECT_DIR / "auth
|
|||||||
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
|
PROJECT_LOCATOR_SCHEMA = "napseer.project.v1"
|
||||||
PROJECT_LOCATOR_FILENAME = "project.json"
|
PROJECT_LOCATOR_FILENAME = "project.json"
|
||||||
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
|
PROJECT_LOCATOR_FIELDS = {"schema", "base_url", "project_id", "project_slug"}
|
||||||
# Optional, non-identity locator preferences. Values are validated where
|
# Committed, non-identity tool preferences live at the project root, outside
|
||||||
# they are consumed so an unknown preference never blocks project identity.
|
# .napseer/: the locator schema stays strict and released clients that treat
|
||||||
PROJECT_LOCATOR_OPTIONAL_FIELDS = {"tool_profiles"}
|
# other .napseer/ entries as private state awaiting migration never see it.
|
||||||
|
PROJECT_TOOLS_SCHEMA = "napseer.tools.v1"
|
||||||
|
PROJECT_TOOLS_FILENAME = ".napseer-tools.json"
|
||||||
|
PROJECT_TOOLS_MAX_BYTES = 4096
|
||||||
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"
|
||||||
@@ -874,7 +877,7 @@ def validate_project_locator(data):
|
|||||||
"project_locator_invalid",
|
"project_locator_invalid",
|
||||||
".napseer/project.json must contain one JSON object.",
|
".napseer/project.json must contain one JSON object.",
|
||||||
)
|
)
|
||||||
if not PROJECT_LOCATOR_FIELDS <= set(data) or set(data) - PROJECT_LOCATOR_FIELDS - PROJECT_LOCATOR_OPTIONAL_FIELDS:
|
if set(data) != PROJECT_LOCATOR_FIELDS:
|
||||||
raise SafeToolError(
|
raise SafeToolError(
|
||||||
"project_locator_invalid",
|
"project_locator_invalid",
|
||||||
".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.",
|
".napseer/project.json has unsupported or missing fields. Regenerate it with a current nap CLI.",
|
||||||
@@ -922,15 +925,12 @@ def validate_project_locator(data):
|
|||||||
".napseer/project.json project_slug is invalid.",
|
".napseer/project.json project_slug is invalid.",
|
||||||
)
|
)
|
||||||
|
|
||||||
locator = {
|
return {
|
||||||
"schema": PROJECT_LOCATOR_SCHEMA,
|
"schema": PROJECT_LOCATOR_SCHEMA,
|
||||||
"base_url": raw_base_url,
|
"base_url": raw_base_url,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"project_slug": project_slug,
|
"project_slug": project_slug,
|
||||||
}
|
}
|
||||||
if "tool_profiles" in data:
|
|
||||||
locator["tool_profiles"] = data["tool_profiles"]
|
|
||||||
return locator
|
|
||||||
|
|
||||||
|
|
||||||
def load_project_locator():
|
def load_project_locator():
|
||||||
@@ -1048,9 +1048,6 @@ def write_project_locator(project, *, base_url=None):
|
|||||||
"project_locator_mismatch",
|
"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.",
|
"Local project credentials do not match the committed .napseer/project.json locator. Attach the declared project or intentionally update the locator before continuing.",
|
||||||
)
|
)
|
||||||
if existing and "tool_profiles" in existing:
|
|
||||||
# Preserve the repository's committed preference on rewrite.
|
|
||||||
desired["tool_profiles"] = existing["tool_profiles"]
|
|
||||||
|
|
||||||
ensure_project_locator_ignore_policy()
|
ensure_project_locator_ignore_policy()
|
||||||
path = project_locator_path()
|
path = project_locator_path()
|
||||||
@@ -19723,31 +19720,77 @@ REMOVED_TOOL_REPLACEMENTS = {
|
|||||||
CONFIGURED_TOOL_PROFILES = None
|
CONFIGURED_TOOL_PROFILES = None
|
||||||
|
|
||||||
|
|
||||||
TOOL_PROFILE_LOCATOR_MAX_ITEMS = 16
|
TOOL_PROFILE_FILE_MAX_ITEMS = 16
|
||||||
|
|
||||||
|
|
||||||
def locator_tool_profiles():
|
def project_tools_path():
|
||||||
"""Return (profiles, declared, warnings) from the committed project locator.
|
"""Committed tool preferences at the project root that owns the locator."""
|
||||||
|
return PROJECT_DIR.parent / PROJECT_TOOLS_FILENAME
|
||||||
|
|
||||||
Unknown or malformed entries are ignored with a warning; a locator that
|
|
||||||
cannot be read yields no profiles here (identity errors surface elsewhere).
|
def read_project_tools_file():
|
||||||
|
"""Return the parsed committed tools file, None when absent.
|
||||||
|
|
||||||
|
Raises ValueError with a safe message when the file cannot be used. The
|
||||||
|
file must be a bounded regular JSON file inside the resolved project root.
|
||||||
|
"""
|
||||||
|
import stat
|
||||||
|
|
||||||
|
path = project_tools_path()
|
||||||
|
try:
|
||||||
|
if not path.exists() and not path.is_symlink():
|
||||||
|
return None
|
||||||
|
if path.is_symlink():
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file, not a symlink.")
|
||||||
|
root = path.parent.resolve()
|
||||||
|
if path.resolve().parent != root:
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must stay inside the project root.")
|
||||||
|
fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
|
||||||
|
with os.fdopen(fd, "rb") as source:
|
||||||
|
status = os.fstat(source.fileno())
|
||||||
|
if not stat.S_ISREG(status.st_mode):
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must be a regular file.")
|
||||||
|
if status.st_size > PROJECT_TOOLS_MAX_BYTES:
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
|
||||||
|
body = source.read(PROJECT_TOOLS_MAX_BYTES + 1)
|
||||||
|
except OSError:
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} could not be read.") from None
|
||||||
|
if len(body) > PROJECT_TOOLS_MAX_BYTES:
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} exceeds {PROJECT_TOOLS_MAX_BYTES} bytes.")
|
||||||
|
try:
|
||||||
|
data = json.loads(body.decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, ValueError, RecursionError):
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} is not valid UTF-8 JSON.") from None
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} must contain one JSON object.")
|
||||||
|
if data.get("schema") != PROJECT_TOOLS_SCHEMA:
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} schema must be {PROJECT_TOOLS_SCHEMA}.")
|
||||||
|
if not isinstance(data.get("tool_profiles"), list):
|
||||||
|
raise ValueError(f"{PROJECT_TOOLS_FILENAME} tool_profiles must be an array of profile names.")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def project_file_tool_profiles():
|
||||||
|
"""Return (profiles, applied, warnings) from the committed tools file.
|
||||||
|
|
||||||
|
An unusable file is ignored with a warning (core only); unknown profile
|
||||||
|
names are dropped with a warning. The strict project locator is never
|
||||||
|
consulted for tool preferences.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
locator = load_project_locator()
|
data = read_project_tools_file()
|
||||||
except SafeToolError as exc:
|
except ValueError as exc:
|
||||||
return set(), False, [{"code": "project_locator_unreadable", "message": exc.safe_message}]
|
return set(), False, [{
|
||||||
if not locator or "tool_profiles" not in locator:
|
"code": "tools_file_invalid",
|
||||||
return set(), False, []
|
"message": f"{exc} It was ignored; only core tools are enabled unless NAPSEER_TOOL_PROFILES is set.",
|
||||||
raw = locator["tool_profiles"]
|
|
||||||
if not isinstance(raw, list):
|
|
||||||
return set(), True, [{
|
|
||||||
"code": "invalid_tool_profiles",
|
|
||||||
"message": ".napseer/project.json tool_profiles must be an array of profile names; it was ignored.",
|
|
||||||
}]
|
}]
|
||||||
|
if data is None:
|
||||||
|
return set(), False, []
|
||||||
|
raw = data["tool_profiles"]
|
||||||
known = {"all"} | set(TOOL_PROFILES)
|
known = {"all"} | set(TOOL_PROFILES)
|
||||||
profiles = set()
|
profiles = set()
|
||||||
unknown = []
|
unknown = []
|
||||||
for value in raw[:TOOL_PROFILE_LOCATOR_MAX_ITEMS]:
|
for value in raw[:TOOL_PROFILE_FILE_MAX_ITEMS]:
|
||||||
name = value.strip().lower() if isinstance(value, str) else None
|
name = value.strip().lower() if isinstance(value, str) else None
|
||||||
if name in known:
|
if name in known:
|
||||||
profiles.add(name)
|
profiles.add(name)
|
||||||
@@ -19757,14 +19800,14 @@ def locator_tool_profiles():
|
|||||||
if unknown:
|
if unknown:
|
||||||
warnings.append({
|
warnings.append({
|
||||||
"code": "unknown_tool_profile",
|
"code": "unknown_tool_profile",
|
||||||
"message": ".napseer/project.json tool_profiles contains unknown profile names that were ignored. Supported profiles: "
|
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles contains unknown profile names that were ignored. Supported profiles: "
|
||||||
+ ", ".join(sorted(TOOL_PROFILES)) + ".",
|
+ ", ".join(sorted(TOOL_PROFILES)) + ".",
|
||||||
"ignored": unknown,
|
"ignored": unknown,
|
||||||
})
|
})
|
||||||
if len(raw) > TOOL_PROFILE_LOCATOR_MAX_ITEMS:
|
if len(raw) > TOOL_PROFILE_FILE_MAX_ITEMS:
|
||||||
warnings.append({
|
warnings.append({
|
||||||
"code": "tool_profiles_truncated",
|
"code": "tool_profiles_truncated",
|
||||||
"message": f".napseer/project.json tool_profiles was truncated to {TOOL_PROFILE_LOCATOR_MAX_ITEMS} entries.",
|
"message": f"{PROJECT_TOOLS_FILENAME} tool_profiles was truncated to {TOOL_PROFILE_FILE_MAX_ITEMS} entries.",
|
||||||
})
|
})
|
||||||
return profiles, True, warnings
|
return profiles, True, warnings
|
||||||
|
|
||||||
@@ -19773,7 +19816,7 @@ def tool_profile_configuration():
|
|||||||
"""Return (profiles, source, warnings).
|
"""Return (profiles, source, warnings).
|
||||||
|
|
||||||
Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES,
|
Precedence: in-process override, then a non-empty NAPSEER_TOOL_PROFILES,
|
||||||
then the committed locator's optional tool_profiles, then core only.
|
then the committed .napseer-tools.json at the project root, then core only.
|
||||||
"""
|
"""
|
||||||
if CONFIGURED_TOOL_PROFILES is not None:
|
if CONFIGURED_TOOL_PROFILES is not None:
|
||||||
configured = set(CONFIGURED_TOOL_PROFILES)
|
configured = set(CONFIGURED_TOOL_PROFILES)
|
||||||
@@ -19786,8 +19829,8 @@ def tool_profile_configuration():
|
|||||||
source = "environment"
|
source = "environment"
|
||||||
warnings = []
|
warnings = []
|
||||||
else:
|
else:
|
||||||
configured, declared, warnings = locator_tool_profiles()
|
configured, applied, warnings = project_file_tool_profiles()
|
||||||
source = "project_locator" if declared else "default"
|
source = "tools_file" if applied else "default"
|
||||||
return configured, source, warnings
|
return configured, source, warnings
|
||||||
unknown = set(configured) - ({"all"} | set(TOOL_PROFILES))
|
unknown = set(configured) - ({"all"} | set(TOOL_PROFILES))
|
||||||
if unknown:
|
if unknown:
|
||||||
@@ -20050,7 +20093,8 @@ def contract_profile(args=None):
|
|||||||
"active": sorted(active_profiles),
|
"active": sorted(active_profiles),
|
||||||
"source": profile_source,
|
"source": profile_source,
|
||||||
"warnings": profile_warnings,
|
"warnings": profile_warnings,
|
||||||
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer/project.json tool_profiles",
|
"configuration": "NAPSEER_TOOL_PROFILES, else .napseer-tools.json tool_profiles at the project root, else core",
|
||||||
|
"tools_file": str(project_tools_path()),
|
||||||
"available": {
|
"available": {
|
||||||
name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items()
|
name: sorted(tool_names) for name, tool_names in TOOL_PROFILES.items()
|
||||||
},
|
},
|
||||||
@@ -20926,7 +20970,7 @@ def _call_tool_impl_dispatch(name, args):
|
|||||||
"tool_profile_required",
|
"tool_profile_required",
|
||||||
f"{canonical_name} requires the '{profile}' tool profile. Set "
|
f"{canonical_name} requires the '{profile}' tool profile. Set "
|
||||||
f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, "
|
f"NAPSEER_TOOL_PROFILES={profile} (or include it in the comma-separated list) and restart the MCP server, "
|
||||||
f"or add it to tool_profiles in .napseer/project.json.",
|
f"or add it to tool_profiles in .napseer-tools.json at the project root.",
|
||||||
)
|
)
|
||||||
if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS:
|
if name not in PUBLIC_TOOLS and name in PROTECTED_TOOLS:
|
||||||
require_unlocked(name)
|
require_unlocked(name)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
|
import datetime
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import pathlib
|
import pathlib
|
||||||
@@ -513,11 +514,16 @@ def test_manifest_accepts_a_future_compatible_release():
|
|||||||
def test_manifest_rejects_a_minimum_contract_newer_than_installer():
|
def test_manifest_rejects_a_minimum_contract_newer_than_installer():
|
||||||
module = load_installer()
|
module = load_installer()
|
||||||
manifest, _payloads = fake_bundle(module)
|
manifest, _payloads = fake_bundle(module)
|
||||||
manifest["contract"]["current"] = "2026-09-11"
|
# One day past this installer's own contract, whatever it currently is.
|
||||||
manifest["contract"]["minimum_supported"] = "2026-09-11"
|
newer = (
|
||||||
|
datetime.date.fromisoformat(module.CLI_DISTRIBUTION_CONTRACT_VERSION)
|
||||||
|
+ datetime.timedelta(days=1)
|
||||||
|
).isoformat()
|
||||||
|
manifest["contract"]["current"] = newer
|
||||||
|
manifest["contract"]["minimum_supported"] = newer
|
||||||
for item in manifest["items"]:
|
for item in manifest["items"]:
|
||||||
item["contract_version"] = "2026-09-11"
|
item["contract_version"] = newer
|
||||||
item["minimum_contract_version"] = "2026-09-11"
|
item["minimum_contract_version"] = newer
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module.validated_manifest_items(manifest)
|
module.validated_manifest_items(manifest)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Regressions for write idempotency, stale local reads, atomic claim-next,
|
"""Regressions for write idempotency, stale local reads, atomic claim-next,
|
||||||
indexed link counts, and locator-declared tool profiles."""
|
indexed link counts, and committed .napseer-tools.json tool profiles."""
|
||||||
|
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
@@ -406,7 +406,7 @@ def test_local_discovery_rows_report_indexed_link_counts(mod):
|
|||||||
assert "links_count" not in summary
|
assert "links_count" not in summary
|
||||||
|
|
||||||
|
|
||||||
# -- 5. Tool profiles from the committed locator ---------------------------
|
# -- 5. Tool profiles from the committed root .napseer-tools.json ----------
|
||||||
|
|
||||||
|
|
||||||
def write_locator(mod, **extra):
|
def write_locator(mod, **extra):
|
||||||
@@ -421,33 +421,153 @@ def write_locator(mod, **extra):
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
def test_locator_tool_profiles_apply_when_env_unset(mod, monkeypatch):
|
def write_tools_file(mod, profiles=None, *, raw=None):
|
||||||
mod.CONFIGURED_TOOL_PROFILES = None
|
path = mod.project_tools_path()
|
||||||
assert mod.configured_tool_profiles() == set()
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if raw is None:
|
||||||
|
raw = json.dumps({"schema": "napseer.tools.v1", "tool_profiles": profiles}) + "\n"
|
||||||
|
path.write_bytes(raw if isinstance(raw, bytes) else raw.encode("utf-8"))
|
||||||
|
return path
|
||||||
|
|
||||||
write_locator(mod, tool_profiles=["workflow", "operations", "bogus"])
|
|
||||||
assert mod.load_project_locator()["project_slug"] == "example"
|
|
||||||
assert mod.configured_tool_profiles() == {"workflow", "operations"}
|
|
||||||
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
|
||||||
|
|
||||||
apropos = mod.explore_tools({})
|
def doctor_warnings(mod):
|
||||||
assert apropos["profile_source"] == "project_locator"
|
|
||||||
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
|
||||||
assert apropos["warnings"][0]["ignored"] == ["bogus"]
|
|
||||||
mod.BASE_URL = "https://api.example.test"
|
mod.BASE_URL = "https://api.example.test"
|
||||||
mod.local_anchor_integrity = lambda: {}
|
mod.local_anchor_integrity = lambda: {}
|
||||||
mod.index_status = lambda args: {}
|
mod.index_status = lambda args: {}
|
||||||
doctor = mod.doctor({})
|
return mod.doctor({}).get("warnings", [])
|
||||||
assert doctor["warnings"][0]["code"] == "unknown_tool_profile"
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_profiles_apply_when_env_unset(mod):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_locator(mod)
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
assert mod.project_tools_path() == mod.project_locator_path().parent.parent / ".napseer-tools.json"
|
||||||
|
# The retired in-.napseer location is never read.
|
||||||
|
(mod.project_locator_path().parent / "tools.json").write_text(
|
||||||
|
json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}))
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
|
||||||
|
write_tools_file(mod, ["workflow", " Operations "])
|
||||||
|
assert mod.tool_profile_configuration() == ({"workflow", "operations"}, "tools_file", [])
|
||||||
|
assert "nap_kanban_claim_next" in mod.enabled_tool_names()
|
||||||
|
apropos = mod.explore_tools({})
|
||||||
|
assert apropos["profile_source"] == "tools_file"
|
||||||
|
assert apropos["active_profiles"] == ["operations", "workflow"]
|
||||||
|
assert "warnings" not in apropos
|
||||||
|
contract = mod.contract_profile({"include_tools": False})["tool_profiles"]
|
||||||
|
assert contract["source"] == "tools_file"
|
||||||
|
assert contract["tools_file"] == str(mod.project_tools_path())
|
||||||
|
assert doctor_warnings(mod) == []
|
||||||
|
# The committed locator still carries identity only.
|
||||||
|
assert set(mod.load_project_locator()) == {"schema", "base_url", "project_id", "project_slug"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides_tools_file(mod, monkeypatch):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", "maintenance")
|
||||||
assert mod.configured_tool_profiles() == {"maintenance"}
|
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||||
|
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||||
assert mod.explore_tools({})["profile_source"] == "environment"
|
assert mod.explore_tools({})["profile_source"] == "environment"
|
||||||
|
|
||||||
monkeypatch.delenv("NAPSEER_TOOL_PROFILES")
|
# A non-empty environment value wins without consulting a broken file.
|
||||||
write_locator(mod, tool_profiles="workflow")
|
write_tools_file(mod, raw="{not json")
|
||||||
assert mod.configured_tool_profiles() == set()
|
assert mod.tool_profile_configuration() == ({"maintenance"}, "environment", [])
|
||||||
assert mod.explore_tools({})["warnings"][0]["code"] == "invalid_tool_profiles"
|
|
||||||
|
# An empty or blank value is unset: the committed file applies again.
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
|
monkeypatch.setenv("NAPSEER_TOOL_PROFILES", " ")
|
||||||
|
assert mod.tool_profile_configuration() == ({"workflow"}, "tools_file", [])
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_unknown_profile_names_are_ignored_with_warning(mod):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow", "bogus", 7])
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == ({"workflow"}, "tools_file")
|
||||||
|
assert [w["code"] for w in warnings] == ["unknown_tool_profile"]
|
||||||
|
assert warnings[0]["ignored"] == ["bogus", "7"]
|
||||||
|
apropos = mod.explore_tools({})
|
||||||
|
assert apropos["warnings"][0]["code"] == "unknown_tool_profile"
|
||||||
|
assert [w["code"] for w in doctor_warnings(mod)] == ["unknown_tool_profile"]
|
||||||
|
|
||||||
|
write_tools_file(mod, ["workflow"] * 20)
|
||||||
|
assert [w["code"] for w in mod.tool_profile_configuration()[2]] == ["tool_profiles_truncated"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"case",
|
||||||
|
["invalid_json", "not_object", "wrong_schema", "missing_schema", "profiles_not_list",
|
||||||
|
"oversized", "not_utf8", "symlink", "directory"],
|
||||||
|
)
|
||||||
|
def test_unusable_tools_file_warns_and_falls_back_to_core(mod, tmp_path_factory, case):
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
marker = "synthetic-marker-not-echoed"
|
||||||
|
raw = {
|
||||||
|
"invalid_json": '{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"], ' + marker,
|
||||||
|
"not_object": json.dumps(["workflow", marker]),
|
||||||
|
"wrong_schema": json.dumps({"schema": "napseer.tools.v9", "tool_profiles": ["workflow"]}),
|
||||||
|
"missing_schema": json.dumps({"tool_profiles": ["workflow"]}),
|
||||||
|
"profiles_not_list": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": "workflow"}),
|
||||||
|
"oversized": json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}) + " " * 4096,
|
||||||
|
"not_utf8": b'{"schema": "napseer.tools.v1", "tool_profiles": ["\xff"]}',
|
||||||
|
}.get(case)
|
||||||
|
path = mod.project_tools_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if case == "symlink":
|
||||||
|
outside = tmp_path_factory.mktemp("outside-project") / ".napseer-tools.json"
|
||||||
|
outside.write_text(json.dumps({"schema": "napseer.tools.v1", "tool_profiles": ["all"]}))
|
||||||
|
path.symlink_to(outside)
|
||||||
|
elif case == "directory":
|
||||||
|
path.mkdir()
|
||||||
|
else:
|
||||||
|
write_tools_file(mod, raw=raw)
|
||||||
|
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == (set(), "default")
|
||||||
|
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||||
|
assert marker not in warnings[0]["message"]
|
||||||
|
assert mod.enabled_tool_names() == set(mod.CORE_TOOL_NAMES)
|
||||||
|
assert mod.explore_tools({})["warnings"][0]["code"] == "tools_file_invalid"
|
||||||
|
assert [w["code"] for w in doctor_warnings(mod)] == ["tools_file_invalid"]
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod._call_tool_impl_dispatch("nap_kanban_claim_next", {})
|
||||||
|
assert excinfo.value.code == "tool_profile_required"
|
||||||
|
assert ".napseer-tools.json" in excinfo.value.safe_message
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_file_parser_recursion_is_contained(mod, monkeypatch):
|
||||||
|
# Older Python JSON decoders raise RecursionError on deep nesting that
|
||||||
|
# still fits the size bound; it must degrade to core, not fail every call.
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_tools_file(mod, ["workflow"])
|
||||||
|
|
||||||
|
def too_deep(*_args, **_kwargs):
|
||||||
|
raise RecursionError("synthetic")
|
||||||
|
|
||||||
|
with monkeypatch.context() as patched:
|
||||||
|
patched.setattr(mod.json, "loads", too_deep)
|
||||||
|
profiles, source, warnings = mod.tool_profile_configuration()
|
||||||
|
assert (profiles, source) == (set(), "default")
|
||||||
|
assert [w["code"] for w in warnings] == ["tools_file_invalid"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_locator_tool_profiles_field_is_still_rejected(mod):
|
||||||
|
# tool_profiles was never a released locator field: the locator keeps its
|
||||||
|
# exact-field schema and the field is neither accepted nor applied.
|
||||||
|
mod.CONFIGURED_TOOL_PROFILES = None
|
||||||
|
write_locator(mod, tool_profiles=["workflow"])
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod.load_project_locator()
|
||||||
|
assert excinfo.value.code == "project_locator_invalid"
|
||||||
|
assert "unsupported or missing fields" in excinfo.value.safe_message
|
||||||
|
with pytest.raises(mod.SafeToolError) as excinfo:
|
||||||
|
mod.write_project_locator({"id": "11111111-1111-4111-8111-111111111111", "slug": "example"},
|
||||||
|
base_url="https://api.example.test")
|
||||||
|
assert excinfo.value.code == "project_locator_invalid"
|
||||||
|
assert "tool_profiles" in json.loads(mod.project_locator_path().read_text())
|
||||||
|
assert mod.tool_profile_configuration() == (set(), "default", [])
|
||||||
|
assert "nap_kanban_claim_next" not in mod.enabled_tool_names()
|
||||||
|
|
||||||
|
|
||||||
# -- 6. Change feed cursor --------------------------------------------------
|
# -- 6. Change feed cursor --------------------------------------------------
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ sys.path.insert(0, str(SCRIPTS))
|
|||||||
from napseer_credentials import CredentialStore, atomic_json, read_json
|
from napseer_credentials import CredentialStore, atomic_json, read_json
|
||||||
from test_user_credentials import provider, worker, outcome
|
from test_user_credentials import provider, worker, outcome
|
||||||
|
|
||||||
|
# Released CLI 0.4.0 source revision (pinned by the Napseer deploy workflow).
|
||||||
|
RELEASED_0_4_0 = 'c427e2432ac85038a4f00b2b888bbdb7c851ada1'
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def worktrees(tmp_path):
|
def worktrees(tmp_path):
|
||||||
@@ -158,3 +161,52 @@ def test_private_state_cannot_be_redirected_into_checkout(worktrees, monkeypatch
|
|||||||
main,_=worktrees
|
main,_=worktrees
|
||||||
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
|
monkeypatch.setenv('NAPSEER_USER_DATA_DIR',str(main/'private-state'))
|
||||||
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)
|
with pytest.raises(CredentialError,match='outside'):WorkspacePaths(main)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_tools_file_resolves_like_locator_and_is_invisible_to_migration(worktrees, monkeypatch):
|
||||||
|
from napseer_credentials import WorkspacePaths
|
||||||
|
main, _ = worktrees
|
||||||
|
monkeypatch.delenv('NAPSEER_TOOL_PROFILES', raising=False)
|
||||||
|
tools = main/'.napseer-tools.json'
|
||||||
|
committed = b'{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n'
|
||||||
|
tools.write_bytes(committed)
|
||||||
|
sub = main/'nested'; sub.mkdir()
|
||||||
|
# A worker started below the project root finds the file at the root that
|
||||||
|
# owns .napseer/project.json, the same way it finds the locator.
|
||||||
|
expression = ("{'path':str(m.project_tools_path()),'profiles':sorted(m.tool_profile_configuration()[0]),"
|
||||||
|
"'source':m.tool_profile_configuration()[1],"
|
||||||
|
"'pending':sorted(p.name for p in m.WORKSPACE.migration_sources())}")
|
||||||
|
result = outcome(worker(sub, expression))['result']
|
||||||
|
assert result == {'path': str(tools), 'profiles': ['workflow'], 'source': 'tools_file', 'pending': []}
|
||||||
|
assert set(p.name for p in (main/'.napseer').iterdir()) <= {'project.json', '.gitignore'}
|
||||||
|
paths = WorkspacePaths(main)
|
||||||
|
assert paths.migration_sources() == []
|
||||||
|
paths.require_migrated()
|
||||||
|
paths.migrate()
|
||||||
|
assert tools.read_bytes() == committed
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_tools_file_is_invisible_to_released_0_4_0_migration_check(worktrees, tmp_path):
|
||||||
|
"""CLI 0.4.0 treats any extra .napseer/ entry as private state to migrate."""
|
||||||
|
import importlib.util
|
||||||
|
released = subprocess.run(
|
||||||
|
['git', '-C', str(SCRIPTS.parent.parent), 'show', f'{RELEASED_0_4_0}:resources/scripts/napseer_credentials.py'],
|
||||||
|
capture_output=True)
|
||||||
|
if released.returncode != 0:
|
||||||
|
pytest.skip(f'released 0.4.0 source {RELEASED_0_4_0} is not in this checkout')
|
||||||
|
source = tmp_path/'napseer_credentials_0_4_0.py'
|
||||||
|
source.write_bytes(released.stdout)
|
||||||
|
spec = importlib.util.spec_from_file_location('napseer_credentials_0_4_0', source)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
main, _ = worktrees
|
||||||
|
(main/'.napseer-tools.json').write_text('{"schema": "napseer.tools.v1", "tool_profiles": ["workflow"]}\n')
|
||||||
|
paths = module.WorkspacePaths(main)
|
||||||
|
assert paths.migration_sources() == []
|
||||||
|
paths.require_migrated()
|
||||||
|
# Control: the same file inside .napseer/ would block 0.4.0 clients.
|
||||||
|
(main/'.napseer/tools.json').write_text('{}\n')
|
||||||
|
assert [p.name for p in paths.migration_sources()] == ['tools.json']
|
||||||
|
with pytest.raises(module.CredentialError):
|
||||||
|
paths.require_migrated()
|
||||||
|
|||||||
Reference in New Issue
Block a user