diff --git a/docs/instance-fleet-identity.md b/docs/instance-fleet-identity.md index d7bbd47..d4bc396 100644 --- a/docs/instance-fleet-identity.md +++ b/docs/instance-fleet-identity.md @@ -49,27 +49,54 @@ model (#949 assumption) as the operating rule for multi-instance fleets. ## How five workers join one instance 1. The host starts one application instance (for example one Codex session). -2. The trusted launcher mints `client_instance_id` (see - `mcp_fleet_snapshot.generate_client_instance_id`) and injects: +2. The **production application launcher** + (`mcp_application_launcher.build_application_mcp_servers` / + `gitea_config.multi_namespace_launcher_entries`) mints exactly one + `client_instance_id` via `mcp_fleet_snapshot.generate_client_instance_id` + and injects the same env into every namespace worker: ```text GITEA_MCP_CLIENT= GITEA_MCP_CLIENT_INSTANCE= + GITEA_MCP_INSTANCE_PROVENANCE=trusted_launcher GITEA_MCP_CLIENT_SESSION= # optional but recommended GITEA_MCP_FLEET_RUN_ID= # when on an approved canary + GITEA_CLIENT_MANAGED=1 + GITEA_MCP_PROFILE= ``` -3. The launcher starts the five MCP namespace processes (or attaches five - role profiles) with **that same environment**. +3. The MCP client starts the five namespace processes (`gitea-author`, + `gitea-reviewer`, `gitea-merger`, `gitea-controller`, `gitea-reconciler`) + from that generated `mcpServers` map — each entry carries the **same** + instance ID. 4. Each worker registers once into the worker registry with its own `worker_identity`, `namespace`, `generation_id`, and PID, but the shared - `client_instance_id`. + `client_instance_id`. Workers never mint a trusted instance ID themselves. + +Only IDs matching the launcher format `inst---` +are trusted. Missing, `legacy-pid-*` / `pid-*` placeholders, and ordinary +user-supplied strings fail closed as untrusted and cannot authorize +multi-instance fleet mutation safety. Worker reconnect (same process, same registration) keeps the instance ID. Worker restart (new process) mints a new worker identity and generation but must still receive the same `GITEA_MCP_CLIENT_INSTANCE` from the parent application if it is the same launch. Full application restart mints a new -`client_instance_id`. +`client_instance_id` (call the launcher without a prior ID). + +### Mutation gate (instance-aware) + +The capability / runtime diagnostic gate +(`_check_mcp_runtimes_diagnostics`) is **instance-aware**: + +* Two legitimate application instances that share a profile (same + `GITEA_MCP_PROFILE`) are allowed when each has a distinct trusted + `client_instance_id`. +* More than one live worker for the same + `(client_instance_id, profile/namespace)` fails closed as a duplicate + namespace worker. +* Multiple processes sharing a profile **without** trusted instance + evidence still fail closed (indistinguishable from a duplicate). ## Approved fleet manifest diff --git a/gitea_config.py b/gitea_config.py index c220012..ffad785 100644 --- a/gitea_config.py +++ b/gitea_config.py @@ -1206,6 +1206,12 @@ RECOGNIZED_GITEA_ENV_KEYS = frozenset({ # #978: operator-approved fleet enrollment identity shared by one launch. "GITEA_MCP_FLEET_RUN_ID", "GITEA_MCP_PROCESS_IDENTITY", + # #978 B1/B2: launcher-sealed provenance + peer-visible worker identity so + # the instance-aware mutation gate can distinguish two legitimate + # application instances that share a profile from a true duplicate worker. + "GITEA_MCP_INSTANCE_PROVENANCE", + "GITEA_MCP_WORKER_IDENTITY", + "GITEA_MCP_GENERATION_ID", # #975 review 652 B1: production also consumes HEARTBEAT_INTERVAL_ENV from # mcp_worker_identity via gitea_mcp_server._start_worker_heartbeat. Omitting # it reproduced the same unsupported-env → runtime_reconnect_required @@ -1239,24 +1245,70 @@ def get_unconsumed_gitea_env_overrides(env=None) -> dict[str, str]: return unconsumed -def launcher_entry(profile_name, config_path=None): +def launcher_entry( + profile_name, + config_path=None, + *, + client_type=None, + client_instance_id=None, + fleet_run_id=None, + session_id=None, + launch_nonce=None, +): """Return a thin MCP launcher entry for *profile_name*. - Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — never a token - or password. Suitable for Claude / Gemini / Codex ``mcpServers`` blocks. + Contains command/args and the GITEA_MCP_* / GITEA_CLIENT_MANAGED env vars — + never a token or password. Suitable for Claude / Gemini / Codex + ``mcpServers`` blocks. + + #978 B1: production launches mint (or reuse) a trusted + ``GITEA_MCP_CLIENT_INSTANCE`` so workers never fall back to the legacy + placeholder identity on the real serve path. Pass *client_instance_id* to + resume the same launch; omit it for a fresh launch (new ID). """ - command, args = server_command() - return { - "gitea-tools": { - "command": command, - "args": args, - "env": { - "GITEA_MCP_CONFIG": config_path or DEFAULT_CONFIG_PATH, - "GITEA_MCP_PROFILE": profile_name, - "GITEA_CLIENT_MANAGED": "1", - }, - } - } + import mcp_application_launcher as app_launcher + + built = app_launcher.launcher_entry_for_profile( + profile_name, + client_type=client_type or "unknown", + config_path=config_path or DEFAULT_CONFIG_PATH, + client_instance_id=client_instance_id, + fleet_run_id=fleet_run_id, + session_id=session_id, + launch_nonce=launch_nonce, + server_key="gitea-tools", + ) + # Public shape stays {server_key: {command, args, env}} for existing callers. + return {"gitea-tools": built["gitea-tools"]} + + +def multi_namespace_launcher_entries( + profile_by_namespace, + *, + client_type, + config_path=None, + client_instance_id=None, + fleet_run_id=None, + session_id=None, + launch_nonce=None, +): + """Build production mcpServers for all five namespaces of one application launch. + + One shared trusted ``client_instance_id`` is minted (or reused) and + propagated to every namespace worker env. See + :func:`mcp_application_launcher.build_application_mcp_servers`. + """ + import mcp_application_launcher as app_launcher + + return app_launcher.build_application_mcp_servers( + profile_by_namespace, + client_type=client_type, + config_path=config_path or DEFAULT_CONFIG_PATH, + client_instance_id=client_instance_id, + fleet_run_id=fleet_run_id, + session_id=session_id, + launch_nonce=launch_nonce, + ) diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index 698c5ce..e365da7 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -15734,6 +15734,9 @@ CLIENT_NAME_ENV = "GITEA_MCP_CLIENT" CLIENT_INSTANCE_ENV = "GITEA_MCP_CLIENT_INSTANCE" CLIENT_SESSION_ENV = "GITEA_MCP_CLIENT_SESSION" FLEET_RUN_ENV = "GITEA_MCP_FLEET_RUN_ID" +INSTANCE_PROVENANCE_ENV = "GITEA_MCP_INSTANCE_PROVENANCE" +WORKER_IDENTITY_ENV = "GITEA_MCP_WORKER_IDENTITY" +GENERATION_ID_ENV = "GITEA_MCP_GENERATION_ID" def _worker_registry(): @@ -15760,28 +15763,51 @@ def _client_identity_hints() -> dict: """What the launcher told us about itself (#948 / #975 / #978). ``client_instance_id`` is established by the trusted application launcher - once per launch and shared by all five namespace workers. When the launcher - key is absent we still register under an explicit *legacy* placeholder so - diagnostic reads work, but the registration is marked incomplete and cannot - authorize multi-instance fleet mutation safety. + once per launch and shared by all five namespace workers. Workers inherit + that value from the production launcher env and never mint a trusted ID + themselves. When the launcher key is absent or untrusted we still register + under an explicit *legacy* placeholder so diagnostic reads work, but the + registration is marked incomplete and cannot authorize multi-instance fleet + mutation safety. """ + import mcp_application_launcher as app_launcher import mcp_fleet_snapshot + inherited = app_launcher.inherit_or_refuse_client_instance(os.environ) client_name = (os.environ.get(CLIENT_NAME_ENV) or "").strip() or None - raw_instance = (os.environ.get(CLIENT_INSTANCE_ENV) or "").strip() or None - instance = mcp_fleet_snapshot.assess_instance_identity(raw_instance) - # Legacy placeholder only when the launcher omitted the key — never invent a - # trusted ID from PID proximity. - if not instance["client_instance_id"]: - placeholder = f"legacy-pid-{os.getpid()}" - instance = mcp_fleet_snapshot.assess_instance_identity( - placeholder, source="pid_fallback" - ) + instance = { + "client_instance_id": inherited.get("client_instance_id"), + "provenance": inherited.get("provenance"), + "trusted": bool(inherited.get("trusted")), + "complete": bool(inherited.get("complete")), + "launcher_sealed": bool(inherited.get("launcher_sealed")), + } + # Legacy placeholder only when the launcher omitted a usable key — never + # invent a trusted ID from PID proximity or ordinary user env. + if not instance["client_instance_id"] or not instance["trusted"]: + if not instance["client_instance_id"]: + placeholder = f"legacy-pid-{os.getpid()}" + fallback = mcp_fleet_snapshot.assess_instance_identity( + placeholder, source="pid_fallback" + ) + instance = { + "client_instance_id": fallback["client_instance_id"], + "provenance": fallback["provenance"], + "trusted": False, + "complete": False, + "launcher_sealed": False, + } + else: + # Malformed / untrusted user-supplied value: keep it for diagnosis + # but never mark trusted. + instance["trusted"] = False + instance["launcher_sealed"] = False return { "client_name": client_name, "client_instance_id": instance["client_instance_id"], "instance_id_provenance": instance["provenance"], "instance_identity_trusted": instance["trusted"], + "instance_launcher_sealed": instance.get("launcher_sealed", False), "session_id": (os.environ.get(CLIENT_SESSION_ENV) or "").strip() or f"proc-{os.getpid()}-{_process_boot_head_sha or 'nohead'}", "fleet_run_id": (os.environ.get(FLEET_RUN_ENV) or "").strip() or None, @@ -15857,6 +15883,19 @@ def _active_worker_identity() -> str | None: if outcome.get("registered"): _WORKER_IDENTITY = identity _WORKER_GENERATION = generation + # #978 B2: export worker/generation/instance into this process + # env so peer process scans (and the instance-aware mutation + # gate) can attribute live workers without treating shared + # profiles as duplicates. Never overwrite a launcher-sealed + # client_instance_id with a different value. + os.environ[WORKER_IDENTITY_ENV] = identity + os.environ[GENERATION_ID_ENV] = generation + if hints.get("client_instance_id") and not ( + os.environ.get(CLIENT_INSTANCE_ENV) or "" + ).strip(): + os.environ[CLIENT_INSTANCE_ENV] = str( + hints["client_instance_id"] + ) # #975: registration is the only moment identity and fencing # epoch are both known, so the heartbeat supervisor is started # here. Without it ``last_heartbeat_at`` never left @@ -22375,6 +22414,20 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> ) peer_worker_identity = peer_env.get("GITEA_MCP_WORKER_IDENTITY") peer_generation = peer_env.get("GITEA_MCP_GENERATION_ID") + # #978 B2: instance attribution for the live fleet gate. Two legitimate + # application instances may share a profile when each has a distinct + # trusted client_instance_id; only same-(instance, profile/namespace) + # duplicates remain blocked. + import mcp_fleet_snapshot as _fleet + + peer_instance_raw = peer_env.get("GITEA_MCP_CLIENT_INSTANCE") + peer_instance_assess = _fleet.assess_instance_identity(peer_instance_raw) + peer_client_instance = ( + peer_instance_assess["client_instance_id"] + if peer_instance_assess.get("trusted") + else None + ) + peer_instance_trusted = bool(peer_instance_assess.get("trusted")) for env_match in re.finditer(r'\b(GITEA_[A-Z0-9_]+)=([^\s]+)', env_out): k, v = env_match.group(1), env_match.group(2) @@ -22392,6 +22445,9 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> "is_client_managed": is_client_managed, "worker_identity": peer_worker_identity, "generation_id": peer_generation, + "client_instance_id": peer_client_instance, + "instance_identity_trusted": peer_instance_trusted, + "raw_client_instance_id": peer_instance_raw, } if profile not in all_profile_procs: all_profile_procs[profile] = [] @@ -22399,44 +22455,85 @@ def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> running_profiles = {} for profile, procs in all_profile_procs.items(): - # #948 AC40/AC43: sharing a profile is legitimate — profile is a - # reusable capability definition, not a worker identity. What is *not* - # legitimate is reusing one worker identity, or two live sessions - # claiming one generation. Distinctness has to be proven, though: - # processes carrying no identity evidence are indistinguishable, so - # they stay classified as duplicates and keep the #686 wall intact. - identified = [p for p in procs if p.get("worker_identity")] - distinct_identities = {p["worker_identity"] for p in identified} - all_identified = len(identified) == len(procs) - duplicate_identity = len(identified) != len(distinct_identities) - contested_generation = any( - len({p["worker_identity"] for p in identified if p.get("generation_id") == gen}) > 1 - for gen in {p.get("generation_id") for p in identified if p.get("generation_id")} - ) - - if len(procs) > 1 and ( - not all_identified or duplicate_identity or contested_generation - ): - pids_str = ", ".join(str(p["pid"]) for p in procs) - if duplicate_identity or contested_generation: - detail = ( - "The same worker identity or generation is claimed more than once, " - "so these are genuine duplicates rather than independent workers." - ) + # #978 B2 / #948 AC40: sharing a profile is legitimate when each live + # process belongs to a distinct trusted application instance. The + # permanent model is instance-aware — not exactly_one_per_profile. + # Fail closed only when: + # * more than one live worker claims the same (client_instance_id, + # profile/namespace), or + # * worker identity / generation is reused, or + # * multiple processes share a profile without trusted instance + # evidence (indistinguishable → treat as duplicate). + # Group by trusted client_instance_id (untrusted/missing → one bucket). + by_instance: dict[str, list[dict]] = {} + for p in procs: + if p.get("instance_identity_trusted") and p.get("client_instance_id"): + key = str(p["client_instance_id"]) else: - detail = ( - "Manual or duplicate launches defeat staleness detection and cannot " - "receive client stdio." + key = "__untrusted_or_missing__" + by_instance.setdefault(key, []).append(p) + + for instance_key, group in by_instance.items(): + if len(group) <= 1: + continue + identified = [p for p in group if p.get("worker_identity")] + distinct_identities = {p["worker_identity"] for p in identified} + all_identified = len(identified) == len(group) + duplicate_identity = len(identified) != len(distinct_identities) + contested_generation = any( + len( + { + p["worker_identity"] + for p in identified + if p.get("generation_id") == gen + } ) - reasons.append( - f"stale-runtime: Duplicate MCP server process(es) detected for profile '{profile}' (PIDs: {pids_str}). " - + detail + > 1 + for gen in { + p.get("generation_id") + for p in identified + if p.get("generation_id") + } ) - # Otherwise: several independently identified workers share one profile. - # No reason is appended, deliberately. Every reason this function - # returns is raised as a hard RuntimeError by its callers, so recording - # legitimate concurrency here as "informational" would block exactly the - # case #948 exists to permit. + # Same trusted (instance, profile) with >1 live process is always a + # duplicate-namespace-worker, even if worker identities differ — + # one application launch gets exactly one worker per namespace. + same_trusted_instance = instance_key != "__untrusted_or_missing__" + if same_trusted_instance or ( + not all_identified or duplicate_identity or contested_generation + ): + pids_str = ", ".join(str(p["pid"]) for p in group) + if same_trusted_instance: + detail = ( + f"More than one live worker for profile '{profile}' under " + f"client_instance_id {instance_key!r} (duplicate namespace " + "worker). Two legitimate application instances with " + "distinct trusted client_instance_id values may share a " + "profile; this collision does not." + ) + elif duplicate_identity or contested_generation: + detail = ( + "The same worker identity or generation is claimed more " + "than once, so these are genuine duplicates rather than " + "independent workers." + ) + else: + detail = ( + "Multiple processes share this profile without distinct " + "trusted client_instance_id evidence, so they cannot be " + "told apart from a duplicate MCP process. Relaunch via " + "the production application launcher so each instance " + "receives a trusted GITEA_MCP_CLIENT_INSTANCE." + ) + reasons.append( + f"stale-runtime: Duplicate MCP server process(es) detected " + f"for profile '{profile}' (PIDs: {pids_str}). " + + detail + ) + # Multiple trusted instances sharing one profile intentionally produce + # no reason here. Callers raise every reason as a hard RuntimeError, so + # treating legitimate multi-instance concurrency as "informational" + # would re-introduce the profile-only wall #978 removes. client_procs = [p for p in procs if p["is_client_managed"]] if client_procs: client_procs.sort(key=lambda p: p["start_time"], reverse=True) diff --git a/mcp_application_launcher.py b/mcp_application_launcher.py new file mode 100644 index 0000000..acfbe9a --- /dev/null +++ b/mcp_application_launcher.py @@ -0,0 +1,393 @@ +"""Production application launcher for multi-namespace MCP fleets (#978 B1). + +One real LLM application launch mints exactly one trusted +``client_instance_id`` and propagates it to every Gitea MCP namespace worker +started for that launch. Workers never invent a trusted instance identity from +PID proximity, timestamps, or ordinary untrusted environment values. + +This module is the production serve-path authority for instance identity. +Tests and fixtures may call the same functions, but production registration +receives the identity from the env this launcher builds — not from a hand-set +test-only helper that bypasses it. +""" + +from __future__ import annotations + +import os +import secrets +from typing import Any, Mapping + +import mcp_fleet_snapshot as fleet +import mcp_worker_identity as mwi + +#: Canonical MCP server names for the five role namespaces. +NAMESPACE_SERVER_NAMES: tuple[str, ...] = ( + "gitea-author", + "gitea-reviewer", + "gitea-merger", + "gitea-controller", + "gitea-reconciler", +) + +#: Map MCP server name → role/namespace kind. +SERVER_TO_NAMESPACE: dict[str, str] = { + "gitea-author": "author", + "gitea-reviewer": "reviewer", + "gitea-merger": "merger", + "gitea-controller": "controller", + "gitea-reconciler": "reconciler", +} + +SANCTIONED_NAMESPACES: tuple[str, ...] = ( + "author", + "reviewer", + "merger", + "controller", + "reconciler", +) + +# Env the launcher may set on every worker of one application launch. +CLIENT_NAME_ENV = "GITEA_MCP_CLIENT" +CLIENT_INSTANCE_ENV = fleet.CLIENT_INSTANCE_ENV +FLEET_RUN_ENV = fleet.FLEET_RUN_ENV +CLIENT_SESSION_ENV = "GITEA_MCP_CLIENT_SESSION" +CLIENT_MANAGED_ENV = "GITEA_CLIENT_MANAGED" +PROFILE_ENV = "GITEA_MCP_PROFILE" +CONFIG_ENV = "GITEA_MCP_CONFIG" +INSTANCE_PROVENANCE_ENV = "GITEA_MCP_INSTANCE_PROVENANCE" +WORKER_IDENTITY_ENV = "GITEA_MCP_WORKER_IDENTITY" +GENERATION_ID_ENV = "GITEA_MCP_GENERATION_ID" + +# Marker the trusted launcher alone writes; ordinary user env without this +# marker is never classified as launcher-trusted provenance. +LAUNCHER_PROVENANCE_VALUE = fleet.INSTANCE_ID_PROVENANCE_TRUSTED + + +def mint_application_launch( + client_type: str | None, + *, + launch_nonce: str | None = None, + fleet_run_id: str | None = None, + session_id: str | None = None, + now=None, +) -> dict[str, Any]: + """Mint one trusted application-instance identity for a production launch. + + Called exactly once per real application launch. The returned + ``client_instance_id`` is injected into every namespace worker environment + for that launch. A second call (separate launch) yields a different ID. + """ + client = mwi.normalize_client_name(client_type) + instance_id = fleet.generate_client_instance_id( + client, launch_nonce=launch_nonce, now=now + ) + assessment = fleet.assess_instance_identity(instance_id) + if not assessment["trusted"]: + # generate_client_instance_id always produces a trusted format; fail + # closed if that invariant ever breaks rather than shipping untrusted. + raise RuntimeError( + f"launcher produced untrusted client_instance_id {instance_id!r}: " + f"{assessment.get('reasons')}" + ) + session = (session_id or "").strip() or f"launch-{secrets.token_hex(12)}" + return { + "client_type": client, + "client_instance_id": instance_id, + "instance_id_provenance": LAUNCHER_PROVENANCE_VALUE, + "instance_identity_trusted": True, + "fleet_run_id": (fleet_run_id or "").strip() or None, + "session_id": session, + "namespaces": list(SANCTIONED_NAMESPACES), + "namespace_server_names": list(NAMESPACE_SERVER_NAMES), + } + + +def namespace_worker_env( + *, + profile_name: str, + client_type: str | None, + client_instance_id: str, + config_path: str | None = None, + fleet_run_id: str | None = None, + session_id: str | None = None, + extra_env: Mapping[str, str] | None = None, +) -> dict[str, str]: + """Build the environment for one namespace worker of a trusted launch. + + Never invents a client_instance_id. The caller must supply the launch-minted + identity so all five workers receive the same value. + """ + assessment = fleet.assess_instance_identity(client_instance_id) + if not assessment["trusted"]: + raise ValueError( + "namespace_worker_env refuses untrusted client_instance_id " + f"{client_instance_id!r}: {assessment.get('reasons')}" + ) + client = mwi.normalize_client_name(client_type) + env: dict[str, str] = { + PROFILE_ENV: str(profile_name), + CLIENT_MANAGED_ENV: "1", + "GITEA_MCP_CLIENT": client, + CLIENT_INSTANCE_ENV: assessment["client_instance_id"], + INSTANCE_PROVENANCE_ENV: LAUNCHER_PROVENANCE_VALUE, + } + if config_path: + env[CONFIG_ENV] = str(config_path) + if fleet_run_id: + env[FLEET_RUN_ENV] = str(fleet_run_id) + if session_id: + env[CLIENT_SESSION_ENV] = str(session_id) + if extra_env: + # Never let untrusted callers override the trusted instance keys. + protected = { + CLIENT_INSTANCE_ENV, + INSTANCE_PROVENANCE_ENV, + "GITEA_MCP_CLIENT", + CLIENT_MANAGED_ENV, + } + for key, value in extra_env.items(): + if key in protected: + continue + env[str(key)] = str(value) + return env + + +def build_application_mcp_servers( + profile_by_namespace: Mapping[str, str], + *, + client_type: str | None, + config_path: str | None = None, + client_instance_id: str | None = None, + fleet_run_id: str | None = None, + session_id: str | None = None, + launch_nonce: str | None = None, + command: str | None = None, + args: list[str] | None = None, + now=None, +) -> dict[str, Any]: + """Build a production ``mcpServers`` map for one application launch. + + Mints one ``client_instance_id`` when *client_instance_id* is omitted (fresh + launch). When the caller supplies a previously minted trusted ID (resume of + the same launch / config rewrite), that ID is reused so reconnect keeps + attribution. A full new application restart omits the ID and receives a + fresh mint. + + Every namespace server entry receives the **same** instance ID. Separate + calls with no supplied ID receive distinct IDs. + """ + missing = [ + ns for ns in SANCTIONED_NAMESPACES if not profile_by_namespace.get(ns) + ] + if missing: + raise ValueError( + "build_application_mcp_servers requires a profile for every " + f"sanctioned namespace; missing: {missing}" + ) + + if client_instance_id is None: + launch = mint_application_launch( + client_type, + launch_nonce=launch_nonce, + fleet_run_id=fleet_run_id, + session_id=session_id, + now=now, + ) + else: + assessment = fleet.assess_instance_identity(client_instance_id) + if not assessment["trusted"]: + raise ValueError( + "refusing to propagate untrusted client_instance_id " + f"{client_instance_id!r} into production launch envs: " + f"{assessment.get('reasons')}" + ) + launch = { + "client_type": mwi.normalize_client_name(client_type), + "client_instance_id": assessment["client_instance_id"], + "instance_id_provenance": LAUNCHER_PROVENANCE_VALUE, + "instance_identity_trusted": True, + "fleet_run_id": (fleet_run_id or "").strip() or None, + "session_id": (session_id or "").strip() + or f"launch-{secrets.token_hex(12)}", + "namespaces": list(SANCTIONED_NAMESPACES), + "namespace_server_names": list(NAMESPACE_SERVER_NAMES), + } + + # Resolve command/args from the production server entry when not provided. + if command is None or args is None: + import gitea_config + + cmd, cmd_args = gitea_config.server_command() + command = command or cmd + args = args if args is not None else list(cmd_args) + + servers: dict[str, Any] = {} + shared_id = launch["client_instance_id"] + for namespace in SANCTIONED_NAMESPACES: + server_name = f"gitea-{namespace}" + profile = profile_by_namespace[namespace] + env = namespace_worker_env( + profile_name=profile, + client_type=launch["client_type"], + client_instance_id=shared_id, + config_path=config_path, + fleet_run_id=launch.get("fleet_run_id"), + session_id=launch.get("session_id"), + ) + servers[server_name] = { + "command": command, + "args": list(args), + "env": env, + } + + return { + "mcpServers": servers, + "launch": launch, + "client_instance_id": shared_id, + "client_type": launch["client_type"], + "namespaces": list(SANCTIONED_NAMESPACES), + "shared_instance_id_across_namespaces": True, + "namespace_count": len(SANCTIONED_NAMESPACES), + } + + +def launcher_entry_for_profile( + profile_name: str, + *, + client_type: str | None = None, + config_path: str | None = None, + client_instance_id: str | None = None, + fleet_run_id: str | None = None, + session_id: str | None = None, + server_key: str = "gitea-tools", + launch_nonce: str | None = None, + now=None, +) -> dict[str, Any]: + """Thin single-server production launcher entry with trusted instance ID. + + Used when only one namespace is being configured. Still mints (or reuses) + a trusted ``client_instance_id`` so production never relies on the legacy + placeholder identity for normal launches. + """ + import gitea_config + + if client_instance_id is None: + launch = mint_application_launch( + client_type or "unknown", + launch_nonce=launch_nonce, + fleet_run_id=fleet_run_id, + session_id=session_id, + now=now, + ) + client_instance_id = launch["client_instance_id"] + client = launch["client_type"] + fleet_run = launch.get("fleet_run_id") + session = launch.get("session_id") + else: + assessment = fleet.assess_instance_identity(client_instance_id) + if not assessment["trusted"]: + raise ValueError( + f"untrusted client_instance_id {client_instance_id!r}" + ) + client = mwi.normalize_client_name(client_type) + fleet_run = (fleet_run_id or "").strip() or None + session = (session_id or "").strip() or None + client_instance_id = assessment["client_instance_id"] + + command, args = gitea_config.server_command() + env = namespace_worker_env( + profile_name=profile_name, + client_type=client, + client_instance_id=client_instance_id, + config_path=config_path or gitea_config.DEFAULT_CONFIG_PATH, + fleet_run_id=fleet_run, + session_id=session, + ) + return { + server_key: { + "command": command, + "args": args, + "env": env, + }, + "client_instance_id": client_instance_id, + "client_type": client, + } + + +def collect_instance_ids_from_mcp_servers( + mcp_servers: Mapping[str, Any], +) -> dict[str, Any]: + """Inspect a production mcpServers map for shared instance attribution. + + Returns the unique set of client_instance_id values across gitea-* servers + and whether all five namespaces share exactly one trusted ID. + """ + ids: list[str] = [] + by_server: dict[str, str | None] = {} + for name in NAMESPACE_SERVER_NAMES: + entry = mcp_servers.get(name) or {} + env = entry.get("env") or {} + raw = (env.get(CLIENT_INSTANCE_ENV) or "").strip() or None + by_server[name] = raw + if raw: + ids.append(raw) + unique = sorted(set(ids)) + trusted = [ + i + for i in unique + if fleet.assess_instance_identity(i)["trusted"] + ] + return { + "server_instance_ids": by_server, + "unique_instance_ids": unique, + "trusted_instance_ids": trusted, + "shared_single_trusted_id": ( + len(unique) == 1 + and len(trusted) == 1 + and all(by_server.get(n) == unique[0] for n in NAMESPACE_SERVER_NAMES) + ), + "namespace_server_count": sum( + 1 for n in NAMESPACE_SERVER_NAMES if n in mcp_servers + ), + } + + +def inherit_or_refuse_client_instance( + env: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """Resolve instance identity for a worker process at serve time. + + Production workers inherit the launcher-issued ID. They never mint a + trusted ID themselves. Missing / legacy / malformed values fail soft into + an untrusted assessment so registration can still record a diagnostic row + without authorizing multi-instance fleet mutation. + """ + source = dict(env if env is not None else os.environ) + raw = (source.get(CLIENT_INSTANCE_ENV) or "").strip() or None + provenance_marker = (source.get(INSTANCE_PROVENANCE_ENV) or "").strip() + assessment = fleet.assess_instance_identity(raw) + # Ordinary user-supplied values without launcher provenance marker are + # still format-checked by assess_instance_identity. When the format is + # trusted but the launcher marker is absent, keep the ID but note that + # provenance is not launcher-sealed (operator hand-set or legacy config). + if assessment["trusted"] and provenance_marker != LAUNCHER_PROVENANCE_VALUE: + assessment = dict(assessment) + assessment["launcher_sealed"] = False + assessment["reasons"] = list(assessment.get("reasons") or []) + [ + f"{INSTANCE_PROVENANCE_ENV} is not {LAUNCHER_PROVENANCE_VALUE!r}; " + "identity format is valid but not sealed by the production launcher" + ] + else: + assessment = dict(assessment) + assessment["launcher_sealed"] = bool( + assessment["trusted"] + and provenance_marker == LAUNCHER_PROVENANCE_VALUE + ) + assessment["fleet_run_id"] = (source.get(FLEET_RUN_ENV) or "").strip() or None + assessment["session_id"] = ( + (source.get(CLIENT_SESSION_ENV) or "").strip() or None + ) + assessment["client_type"] = mwi.normalize_client_name( + (source.get("GITEA_MCP_CLIENT") or "").strip() or None + ) + return assessment diff --git a/mcp_fleet_snapshot.py b/mcp_fleet_snapshot.py index a5b6bb8..b803aff 100644 --- a/mcp_fleet_snapshot.py +++ b/mcp_fleet_snapshot.py @@ -88,8 +88,13 @@ INSTANCE_ID_PROVENANCE_MISSING = "missing" CLIENT_INSTANCE_ENV = "GITEA_MCP_CLIENT_INSTANCE" FLEET_RUN_ENV = "GITEA_MCP_FLEET_RUN_ID" PROCESS_IDENTITY_ENV = "GITEA_MCP_PROCESS_IDENTITY" +INSTANCE_PROVENANCE_ENV = "GITEA_MCP_INSTANCE_PROVENANCE" _LEGACY_INSTANCE_PREFIXES = ("pid-", "proc-", "legacy-") +#: Trusted launcher-issued IDs use the reserved ``inst-`` prefix +#: (see :func:`generate_client_instance_id`). Ordinary user-supplied strings +#: without that prefix never count as trusted attribution. +_TRUSTED_INSTANCE_PREFIX = "inst-" def _utc_now() -> datetime: @@ -131,7 +136,10 @@ def assess_instance_identity( ) -> dict[str, Any]: """Classify whether a client_instance_id is trusted enough for mutation. - Trusted instance IDs are non-empty and not the pre-#978 PID/proc fallbacks. + Trusted instance IDs are non-empty, match the launcher-minted ``inst-…`` + format from :func:`generate_client_instance_id`, and are not pre-#978 + PID/proc/legacy placeholders. Ordinary user-supplied or malformed values + fail closed as untrusted so they cannot spoof multi-instance attribution. Incomplete identities remain visible for diagnosis but cannot authorize unsafe mutation. """ @@ -159,6 +167,20 @@ def assess_instance_identity( "not a trusted launcher-issued instance identity" ], } + if not text.startswith(_TRUSTED_INSTANCE_PREFIX) or len(text) <= len( + _TRUSTED_INSTANCE_PREFIX + ): + return { + "client_instance_id": text, + "complete": False, + "trusted": False, + "provenance": INSTANCE_ID_PROVENANCE_LEGACY, + "reasons": [ + f"client_instance_id {text!r} is malformed or user-supplied and " + f"does not use the trusted launcher prefix " + f"{_TRUSTED_INSTANCE_PREFIX!r}; refusing trusted attribution" + ], + } return { "client_instance_id": text, "complete": True, diff --git a/tests/test_config_menu.py b/tests/test_config_menu.py index 558924a..5b198e7 100644 --- a/tests/test_config_menu.py +++ b/tests/test_config_menu.py @@ -175,8 +175,21 @@ class TestLauncherSnippets(unittest.TestCase): def test_only_safe_keys_no_secrets(self): entry = gitea_config.launcher_entry("prgs", "/cfg/profiles.json")["gitea-tools"] self.assertEqual(set(entry), {"command", "args", "env"}) - self.assertEqual(set(entry["env"]), {"GITEA_MCP_CONFIG", "GITEA_MCP_PROFILE", "GITEA_CLIENT_MANAGED"}) + # #978 B1: production launcher also injects trusted client identity. + required = { + "GITEA_MCP_CONFIG", + "GITEA_MCP_PROFILE", + "GITEA_CLIENT_MANAGED", + "GITEA_MCP_CLIENT", + "GITEA_MCP_CLIENT_INSTANCE", + "GITEA_MCP_INSTANCE_PROVENANCE", + } + self.assertTrue(required.issubset(set(entry["env"])), entry["env"]) self.assertEqual(entry["env"]["GITEA_MCP_PROFILE"], "prgs") + self.assertTrue( + entry["env"]["GITEA_MCP_CLIENT_INSTANCE"].startswith("inst-"), + entry["env"]["GITEA_MCP_CLIENT_INSTANCE"], + ) blob = json.dumps(entry).lower() for word in ("token", "password", "secret"): self.assertNotIn(word, blob) diff --git a/tests/test_issue_978_instance_fleet_snapshot.py b/tests/test_issue_978_instance_fleet_snapshot.py index 8ce41e0..153d50e 100644 --- a/tests/test_issue_978_instance_fleet_snapshot.py +++ b/tests/test_issue_978_instance_fleet_snapshot.py @@ -1049,6 +1049,386 @@ class ClientHintsTests(unittest.TestCase): self.assertEqual(hints["client_instance_id"], inst) self.assertEqual(hints["fleet_run_id"], "run-1") + def test_malformed_user_supplied_instance_not_trusted(self): + import gitea_mcp_server as server + + with mock.patch.dict( + os.environ, + { + "GITEA_MCP_CLIENT": "codex", + "GITEA_MCP_CLIENT_INSTANCE": "user-spoofed-not-launcher", + }, + clear=False, + ): + hints = server._client_identity_hints() + self.assertFalse(hints["instance_identity_trusted"]) + self.assertEqual(hints["client_instance_id"], "user-spoofed-not-launcher") + + +class ProductionLauncherB1Tests(unittest.TestCase): + """#978 review 655 B1: production launcher mints + propagates instance ID.""" + + def test_one_launch_shares_id_across_five_namespaces(self): + import mcp_application_launcher as launcher + + profiles = {ns: f"prgs-{ns}" for ns in NAMESPACES} + built = launcher.build_application_mcp_servers( + profiles, + client_type="codex", + config_path="/cfg/profiles.json", + launch_nonce="launch-one", + now=NOW, + command="/venv/bin/python3", + args=["mcp_server.py"], + ) + servers = built["mcpServers"] + self.assertEqual(len(servers), 5) + ids = { + servers[f"gitea-{ns}"]["env"]["GITEA_MCP_CLIENT_INSTANCE"] + for ns in NAMESPACES + } + self.assertEqual(len(ids), 1, ids) + shared = next(iter(ids)) + self.assertTrue(fleet.assess_instance_identity(shared)["trusted"]) + self.assertEqual(built["client_instance_id"], shared) + # No legacy placeholder on any production worker env. + for ns in NAMESPACES: + env = servers[f"gitea-{ns}"]["env"] + self.assertEqual(env["GITEA_MCP_CLIENT_INSTANCE"], shared) + self.assertEqual( + env["GITEA_MCP_INSTANCE_PROVENANCE"], + fleet.INSTANCE_ID_PROVENANCE_TRUSTED, + ) + self.assertEqual(env["GITEA_MCP_CLIENT"], "codex") + self.assertEqual(env["GITEA_CLIENT_MANAGED"], "1") + self.assertFalse(env["GITEA_MCP_CLIENT_INSTANCE"].startswith("legacy-")) + + proof = launcher.collect_instance_ids_from_mcp_servers(servers) + self.assertTrue(proof["shared_single_trusted_id"], proof) + + def test_two_launches_receive_different_ids(self): + import mcp_application_launcher as launcher + + profiles = {ns: f"prgs-{ns}" for ns in NAMESPACES} + a = launcher.build_application_mcp_servers( + profiles, + client_type="codex", + launch_nonce="L1", + now=NOW, + command="python3", + args=["mcp_server.py"], + ) + b = launcher.build_application_mcp_servers( + profiles, + client_type="codex", + launch_nonce="L2", + now=NOW, + command="python3", + args=["mcp_server.py"], + ) + self.assertNotEqual(a["client_instance_id"], b["client_instance_id"]) + + def test_resume_reuses_supplied_trusted_id(self): + import mcp_application_launcher as launcher + + profiles = {ns: f"prgs-{ns}" for ns in NAMESPACES} + first = launcher.build_application_mcp_servers( + profiles, + client_type="claude_code", + launch_nonce="resume-src", + now=NOW, + command="python3", + args=["mcp_server.py"], + ) + resumed = launcher.build_application_mcp_servers( + profiles, + client_type="claude_code", + client_instance_id=first["client_instance_id"], + command="python3", + args=["mcp_server.py"], + ) + self.assertEqual(first["client_instance_id"], resumed["client_instance_id"]) + + def test_refuses_untrusted_instance_id_on_production_path(self): + import mcp_application_launcher as launcher + + profiles = {ns: f"prgs-{ns}" for ns in NAMESPACES} + with self.assertRaises(ValueError): + launcher.build_application_mcp_servers( + profiles, + client_type="codex", + client_instance_id="pid-999", + command="python3", + args=["mcp_server.py"], + ) + with self.assertRaises(ValueError): + launcher.namespace_worker_env( + profile_name="prgs-author", + client_type="codex", + client_instance_id="user-supplied-garbage", + ) + + def test_extra_env_cannot_override_trusted_instance_keys(self): + import mcp_application_launcher as launcher + + trusted = fleet.generate_client_instance_id( + "codex", launch_nonce="seal", now=NOW + ) + env = launcher.namespace_worker_env( + profile_name="prgs-author", + client_type="codex", + client_instance_id=trusted, + extra_env={ + "GITEA_MCP_CLIENT_INSTANCE": "inst-spoofed-20260730T000000Z-deadbeefdead", + "GITEA_MCP_INSTANCE_PROVENANCE": "trusted_launcher", + "GITEA_CLIENT_MANAGED": "0", + "GITEA_MCP_CLIENT": "gemini", + "GITEA_AUTHOR_WORKTREE": "/ok/extra", + }, + ) + self.assertEqual(env["GITEA_MCP_CLIENT_INSTANCE"], trusted) + self.assertEqual(env["GITEA_CLIENT_MANAGED"], "1") + self.assertEqual(env["GITEA_MCP_CLIENT"], "codex") + self.assertEqual(env["GITEA_AUTHOR_WORKTREE"], "/ok/extra") + + def test_gitea_config_launcher_entry_uses_production_path(self): + """Production launcher_entry — not a test helper — mints trusted IDs.""" + import gitea_config + + entry_a = gitea_config.launcher_entry( + "prgs-author", + "/cfg/profiles.json", + client_type="codex", + launch_nonce="cfg-a", + )["gitea-tools"] + entry_b = gitea_config.launcher_entry( + "prgs-author", + "/cfg/profiles.json", + client_type="codex", + launch_nonce="cfg-b", + )["gitea-tools"] + id_a = entry_a["env"]["GITEA_MCP_CLIENT_INSTANCE"] + id_b = entry_b["env"]["GITEA_MCP_CLIENT_INSTANCE"] + self.assertNotEqual(id_a, id_b) + self.assertTrue(fleet.assess_instance_identity(id_a)["trusted"]) + self.assertTrue(fleet.assess_instance_identity(id_b)["trusted"]) + + def test_multi_namespace_launcher_entries_api(self): + import gitea_config + + profiles = {ns: f"prgs-{ns}" for ns in NAMESPACES} + built = gitea_config.multi_namespace_launcher_entries( + profiles, + client_type="grok", + config_path="/cfg/profiles.json", + launch_nonce="multi-api", + ) + self.assertTrue(built["shared_instance_id_across_namespaces"]) + self.assertEqual(built["namespace_count"], 5) + proof = __import__( + "mcp_application_launcher", fromlist=["*"] + ).collect_instance_ids_from_mcp_servers(built["mcpServers"]) + self.assertTrue(proof["shared_single_trusted_id"], proof) + + def test_malformed_and_missing_fail_safely(self): + self.assertFalse(fleet.assess_instance_identity("")["trusted"]) + self.assertFalse(fleet.assess_instance_identity(None)["trusted"]) + self.assertFalse(fleet.assess_instance_identity("legacy-pid-1")["trusted"]) + self.assertFalse(fleet.assess_instance_identity("not-an-inst-id")["trusted"]) + self.assertFalse(fleet.assess_instance_identity("inst-")["trusted"]) + self.assertFalse(fleet.assess_instance_identity("user-spoofed")["trusted"]) + # Reserved inst- prefix from the launcher is trusted. + self.assertTrue(fleet.assess_instance_identity("inst-codex-1")["trusted"]) + + +class InstanceAwareMutationGateB2Tests(unittest.TestCase): + """#978 review 655 B2: mutation gate is instance-aware, not profile-only.""" + + def _ps_lines(self, rows): + # rows: list of (pid, lstart, command) — command ignored beyond mcp_server.py + header = " PID LSTART COMMAND\n" + body = "".join( + f"{pid} {lstart} /path/to/python mcp_server.py\n" + for pid, lstart, _env in rows + ) + return header + body + + def _side_effect(self, rows, *, self_pid=1000): + ps_out = self._ps_lines(rows) + env_by_pid = {str(pid): env for pid, _ls, env in rows} + + def side_effect(args, **kwargs): + mock_run = mock.MagicMock() + if args[0] == "ps" and "eww" in args: + mock_run.stdout = env_by_pid.get(str(args[2]), "") + return mock_run + if args[0] == "ps": + mock_run.stdout = ps_out + return mock_run + if args[0] == "git": + mock_run.stdout = "deadbeef" + return mock_run + raise ValueError(args) + + return side_effect + + def test_two_valid_instances_same_profile_allowed(self): + import gitea_mcp_server as server + + inst_a = fleet.generate_client_instance_id( + "codex", launch_nonce="gate-a", now=NOW + ) + inst_b = fleet.generate_client_instance_id( + "codex", launch_nonce="gate-b", now=NOW + ) + lstart = "Wed Jul 8 15:00:00 2026" + rows = [ + ( + 2001, + lstart, + ( + f"GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1 " + f"GITEA_MCP_CLIENT_INSTANCE={inst_a} " + f"GITEA_MCP_WORKER_IDENTITY=worker-a " + f"GITEA_MCP_GENERATION_ID=gen-a" + ), + ), + ( + 2002, + lstart, + ( + f"GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1 " + f"GITEA_MCP_CLIENT_INSTANCE={inst_b} " + f"GITEA_MCP_WORKER_IDENTITY=worker-b " + f"GITEA_MCP_GENERATION_ID=gen-b" + ), + ), + ] + with mock.patch("subprocess.run") as mock_run, mock.patch( + "os.path.getmtime", return_value=datetime(2026, 7, 8, 11, 0, 0).timestamp() + ), mock.patch("os.path.exists", return_value=True), mock.patch( + "os.getpid", return_value=9999 + ): + mock_run.side_effect = self._side_effect(rows) + reasons = server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + self.assertFalse( + any("Duplicate MCP server process(es) detected" in r for r in reasons), + reasons, + ) + + def test_duplicate_same_instance_and_namespace_blocked(self): + import gitea_mcp_server as server + + inst = fleet.generate_client_instance_id( + "codex", launch_nonce="dup", now=NOW + ) + lstart = "Wed Jul 8 15:00:00 2026" + rows = [ + ( + 3001, + lstart, + ( + f"GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1 " + f"GITEA_MCP_CLIENT_INSTANCE={inst} " + f"GITEA_MCP_WORKER_IDENTITY=worker-1 " + f"GITEA_MCP_GENERATION_ID=gen-1" + ), + ), + ( + 3002, + lstart, + ( + f"GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1 " + f"GITEA_MCP_CLIENT_INSTANCE={inst} " + f"GITEA_MCP_WORKER_IDENTITY=worker-2 " + f"GITEA_MCP_GENERATION_ID=gen-2" + ), + ), + ] + with mock.patch("subprocess.run") as mock_run, mock.patch( + "os.path.getmtime", return_value=datetime(2026, 7, 8, 11, 0, 0).timestamp() + ), mock.patch("os.path.exists", return_value=True), mock.patch( + "os.getpid", return_value=9999 + ): + mock_run.side_effect = self._side_effect(rows) + reasons = server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + self.assertTrue( + any("Duplicate MCP server process(es) detected" in r for r in reasons), + reasons, + ) + self.assertTrue( + any("duplicate namespace" in r.lower() or "client_instance_id" in r for r in reasons), + reasons, + ) + + def test_missing_instance_evidence_still_fail_closed(self): + """Two profile-sharing processes without trusted IDs remain blocked.""" + import gitea_mcp_server as server + + lstart = "Wed Jul 8 15:00:00 2026" + rows = [ + ( + 4001, + lstart, + "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1", + ), + ( + 4002, + lstart, + "GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1", + ), + ] + with mock.patch("subprocess.run") as mock_run, mock.patch( + "os.path.getmtime", return_value=datetime(2026, 7, 8, 11, 0, 0).timestamp() + ), mock.patch("os.path.exists", return_value=True), mock.patch( + "os.getpid", return_value=9999 + ): + mock_run.side_effect = self._side_effect(rows) + reasons = server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + self.assertTrue( + any("Duplicate MCP server process(es) detected" in r for r in reasons), + reasons, + ) + + def test_single_instance_still_ok(self): + import gitea_mcp_server as server + + inst = fleet.generate_client_instance_id( + "codex", launch_nonce="single", now=NOW + ) + lstart = "Wed Jul 8 15:00:00 2026" + rows = [ + ( + 5001, + lstart, + ( + f"GITEA_MCP_PROFILE=prgs-author GITEA_CLIENT_MANAGED=1 " + f"GITEA_MCP_CLIENT_INSTANCE={inst} " + f"GITEA_MCP_WORKER_IDENTITY=worker-only " + f"GITEA_MCP_GENERATION_ID=gen-only" + ), + ), + ] + with mock.patch("subprocess.run") as mock_run, mock.patch( + "os.path.getmtime", return_value=datetime(2026, 7, 8, 11, 0, 0).timestamp() + ), mock.patch("os.path.exists", return_value=True), mock.patch( + "os.getpid", return_value=9999 + ): + mock_run.side_effect = self._side_effect(rows) + reasons = server._check_mcp_runtimes_diagnostics( + "create_issue", ["prgs-author"] + ) + self.assertFalse( + any("Duplicate MCP server process(es) detected" in r for r in reasons), + reasons, + ) + if __name__ == "__main__": unittest.main()