Issue #985. The production launcher could mint one trusted
GITEA_MCP_CLIENT_INSTANCE per launch, but two gaps kept real launches on
untrusted legacy-pid-* identities:
1. build_application_mcp_servers required a profile for all five sanctioned
namespaces, so a project-scoped configuration exposing only author,
reviewer, and merger could not use it without inventing controller and
reconciler profiles that must not exist.
2. The module produced configuration data but had no runnable entry point, so
every real launch bypassed it entirely.
Changes:
- resolve_launch_namespaces() validates an explicit namespace subset as an
allow-list; unknown, duplicate, and empty selections are refused rather than
silently narrowing a launch. Omitting it preserves five-namespace behaviour.
- build_application_mcp_servers() accepts that subset, requires profiles only
for the launched namespaces, starts only those workers, and reports
excluded_namespaces / project_scoped.
- collect_instance_ids_from_mcp_servers() inspects the launch's own namespaces
instead of an assumed five, and reports missing_servers, so a three-namespace
launch can prove shared attribution without reading as two absent workers.
- Runnable entry point: python3 -m mcp_application_launcher mints one trusted
identity, writes a per-launch 0600 mcpServers config, and execs the client.
CLIENT_LAUNCH_SPECS is a data-driven registry so other supported clients use
the same mint-once/propagate-to-all mechanism. --dry-run prints the plan.
- Provenance sealing now fails closed. The inst- format is public and
reproducible, so format alone could previously let anyone who set one
environment variable manufacture a trusted identity. Trust now additionally
requires GITEA_MCP_INSTANCE_PROVENANCE=trusted_launcher, which only the
launcher writes; a well-formed but unsealed value is classified
unsealed_launcher and refused, while still being reported for diagnosis.
Deliberate behaviour change: tests/test_issue_978_instance_fleet_snapshot.py
test_client_hints_trusted_when_set previously asserted that a well-formed ID
alone was trusted. It now supplies the launcher seal, and a new companion test
asserts the unsealed case fails closed. This tightens the contract; no
assertion was weakened.
No static or persistent per-project instance IDs are introduced, duplicate
worker and cohort detection are untouched, and no fleet or mutation gate is
relaxed.
Tests: tests/test_issue_985_project_scoped_launcher.py, 47 passed, 3 subtests.
Full suite from a branches/ worktree: 28 failed, 6252 passed, 6 skipped against
a master baseline at 32ab8392 of 28 failed, 6204 passed, 6 skipped; the failing
sets are byte-identical, so zero regressions and zero masked failures.
Closes #985
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
754 lines
27 KiB
Python
754 lines
27 KiB
Python
"""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, Sequence
|
|
|
|
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 resolve_launch_namespaces(
|
|
namespaces: "Sequence[str] | None" = None,
|
|
) -> tuple[str, ...]:
|
|
"""Validate an explicit project-scoped namespace subset (#985).
|
|
|
|
``None`` keeps the historical whole-fleet behaviour and resolves to all five
|
|
sanctioned namespaces. An explicit sequence is validated against
|
|
:data:`SANCTIONED_NAMESPACES` and returned in canonical fleet order, so a
|
|
project-scoped configuration (for example author/reviewer/merger only) can
|
|
launch without inventing controller or reconciler profiles.
|
|
|
|
This is an allow-list, not a filter: anything outside the sanctioned set is
|
|
refused rather than silently dropped, so a typo can never quietly shrink a
|
|
launch to fewer workers than the operator intended.
|
|
"""
|
|
if namespaces is None:
|
|
return tuple(SANCTIONED_NAMESPACES)
|
|
|
|
requested = [str(ns).strip().lower() for ns in namespaces]
|
|
if not requested or any(not ns for ns in requested):
|
|
raise ValueError(
|
|
"namespaces must be a non-empty sequence of sanctioned namespace "
|
|
f"names; choose from {list(SANCTIONED_NAMESPACES)}"
|
|
)
|
|
|
|
unknown = sorted({ns for ns in requested if ns not in SANCTIONED_NAMESPACES})
|
|
if unknown:
|
|
raise ValueError(
|
|
f"unsanctioned namespace(s) {unknown} requested; sanctioned "
|
|
f"namespaces are {list(SANCTIONED_NAMESPACES)}"
|
|
)
|
|
|
|
duplicates = sorted({ns for ns in requested if requested.count(ns) > 1})
|
|
if duplicates:
|
|
raise ValueError(
|
|
f"duplicate namespace(s) {duplicates} requested; each sanctioned "
|
|
"namespace may appear at most once in one launch"
|
|
)
|
|
|
|
selected = set(requested)
|
|
return tuple(ns for ns in SANCTIONED_NAMESPACES if ns in selected)
|
|
|
|
|
|
def build_application_mcp_servers(
|
|
profile_by_namespace: Mapping[str, str],
|
|
*,
|
|
client_type: str | None,
|
|
namespaces: "Sequence[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,
|
|
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.
|
|
|
|
*namespaces* selects an explicitly validated project-scoped subset (#985).
|
|
Omitting it preserves the original whole-fleet behaviour, so existing
|
|
five-namespace callers are unaffected. Only the resolved namespaces need a
|
|
profile, and only they are started; excluded namespaces are reported so a
|
|
caller can prove a controller/reconciler worker was never launched.
|
|
"""
|
|
resolved_namespaces = resolve_launch_namespaces(namespaces)
|
|
|
|
missing = [
|
|
ns for ns in resolved_namespaces if not profile_by_namespace.get(ns)
|
|
]
|
|
if missing:
|
|
raise ValueError(
|
|
"build_application_mcp_servers requires a profile for every "
|
|
f"requested 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),
|
|
}
|
|
|
|
# The launch record describes only the namespaces this launch actually
|
|
# starts, so downstream attribution never implies an unstarted worker.
|
|
launch["namespaces"] = list(resolved_namespaces)
|
|
launch["namespace_server_names"] = [
|
|
f"gitea-{ns}" for ns in resolved_namespaces
|
|
]
|
|
|
|
# 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 resolved_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,
|
|
}
|
|
|
|
excluded = [ns for ns in SANCTIONED_NAMESPACES if ns not in resolved_namespaces]
|
|
return {
|
|
"mcpServers": servers,
|
|
"launch": launch,
|
|
"client_instance_id": shared_id,
|
|
"client_type": launch["client_type"],
|
|
"namespaces": list(resolved_namespaces),
|
|
"excluded_namespaces": excluded,
|
|
"project_scoped": bool(excluded),
|
|
"shared_instance_id_across_namespaces": True,
|
|
"namespace_count": len(resolved_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],
|
|
*,
|
|
namespaces: "Sequence[str] | None" = None,
|
|
) -> dict[str, Any]:
|
|
"""Inspect a production mcpServers map for shared instance attribution.
|
|
|
|
Returns the unique set of client_instance_id values across the gitea-*
|
|
servers of one launch and whether every worker of that launch shares
|
|
exactly one trusted ID.
|
|
|
|
The inspected set is the launch's own namespaces, not an assumed five
|
|
(#985): a project-scoped author/reviewer/merger launch must be able to
|
|
prove shared attribution without a controller or reconciler entry counting
|
|
as a missing worker. Pass *namespaces* to assert an exact expected subset;
|
|
omit it to inspect whichever sanctioned namespace servers are present.
|
|
"""
|
|
if namespaces is None:
|
|
expected_servers = [
|
|
name for name in NAMESPACE_SERVER_NAMES if name in mcp_servers
|
|
]
|
|
else:
|
|
expected_servers = [
|
|
f"gitea-{ns}" for ns in resolve_launch_namespaces(namespaces)
|
|
]
|
|
|
|
ids: list[str] = []
|
|
by_server: dict[str, str | None] = {}
|
|
for name in expected_servers:
|
|
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"]
|
|
]
|
|
missing_servers = [n for n in expected_servers if n not in mcp_servers]
|
|
return {
|
|
"server_instance_ids": by_server,
|
|
"unique_instance_ids": unique,
|
|
"trusted_instance_ids": trusted,
|
|
"inspected_servers": list(expected_servers),
|
|
"missing_servers": missing_servers,
|
|
"shared_single_trusted_id": (
|
|
bool(expected_servers)
|
|
and not missing_servers
|
|
and len(unique) == 1
|
|
and len(trusted) == 1
|
|
and all(by_server.get(n) == unique[0] for n in expected_servers)
|
|
),
|
|
"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 = dict(fleet.assess_instance_identity(raw))
|
|
# The ``inst-…`` format is public and reproducible, so format alone cannot
|
|
# establish trust: anyone able to set one env var could otherwise hand-write
|
|
# a well-formed ID and be believed. Trust therefore requires the launcher's
|
|
# own seal as well, and a well-formed but unsealed identity fails closed
|
|
# rather than degrading to a mere annotation (#985).
|
|
if assessment["trusted"] and provenance_marker != LAUNCHER_PROVENANCE_VALUE:
|
|
assessment["launcher_sealed"] = False
|
|
assessment["trusted"] = False
|
|
assessment["complete"] = False
|
|
assessment["provenance"] = fleet.INSTANCE_ID_PROVENANCE_UNSEALED
|
|
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, so trusted attribution is refused"
|
|
]
|
|
else:
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Runnable launch path (#985)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
#: How each supported LLM client is started with a per-launch MCP config.
|
|
#:
|
|
#: The launch interface is deliberately a data-driven registry rather than a
|
|
#: Claude-specific code path: adding another supported client is one entry, and
|
|
#: every client necessarily goes through the same mint-once/propagate-to-all
|
|
#: identity mechanism because the argv builder only ever receives a config file
|
|
#: this module wrote.
|
|
CLIENT_LAUNCH_SPECS: dict[str, dict[str, Any]] = {
|
|
"claude_code": {
|
|
"command": "claude",
|
|
"config_args": lambda path: ["--mcp-config", path, "--strict-mcp-config"],
|
|
"docs": "claude --mcp-config <per-launch.json> --strict-mcp-config",
|
|
},
|
|
}
|
|
|
|
|
|
def supported_launch_clients() -> tuple[str, ...]:
|
|
"""Client types with a sanctioned runnable launch path."""
|
|
return tuple(sorted(CLIENT_LAUNCH_SPECS))
|
|
|
|
|
|
def build_client_launch_argv(
|
|
client_type: str,
|
|
config_path: str,
|
|
*,
|
|
extra_args: "Sequence[str] | None" = None,
|
|
) -> list[str]:
|
|
"""Build the argv that starts *client_type* against a per-launch config."""
|
|
client = mwi.normalize_client_name(client_type)
|
|
spec = CLIENT_LAUNCH_SPECS.get(client)
|
|
if spec is None:
|
|
raise ValueError(
|
|
f"no sanctioned runnable launch path for client {client!r}; "
|
|
f"supported clients: {list(supported_launch_clients())}"
|
|
)
|
|
argv = [str(spec["command"])] + list(spec["config_args"](str(config_path)))
|
|
if extra_args:
|
|
argv.extend(str(a) for a in extra_args)
|
|
return argv
|
|
|
|
|
|
def write_launch_config(
|
|
mcp_servers: Mapping[str, Any],
|
|
*,
|
|
directory: str | None = None,
|
|
) -> str:
|
|
"""Write one launch's ``mcpServers`` config to a fresh per-launch file.
|
|
|
|
The file is per-launch and owner-readable only. #985 explicitly rules out
|
|
persisting an instance ID into a shared, reused ``.mcp.json``: two
|
|
concurrent sessions reading one static trusted ID is precisely the reuse
|
|
case the duplicate guard must reject. A distinct file per launch keeps the
|
|
minted identity bound to the launch that minted it.
|
|
"""
|
|
import json
|
|
import tempfile
|
|
|
|
fd, path = tempfile.mkstemp(
|
|
prefix="gitea-mcp-launch-", suffix=".json", dir=directory
|
|
)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
json.dump({"mcpServers": dict(mcp_servers)}, handle, indent=2)
|
|
handle.write("\n")
|
|
except Exception:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
return path
|
|
|
|
|
|
def prepare_application_launch(
|
|
profile_by_namespace: Mapping[str, str],
|
|
*,
|
|
client_type: str,
|
|
namespaces: "Sequence[str] | None" = None,
|
|
config_path: str | None = None,
|
|
fleet_run_id: str | None = None,
|
|
session_id: str | None = None,
|
|
launch_nonce: str | None = None,
|
|
extra_args: "Sequence[str] | None" = None,
|
|
config_directory: str | None = None,
|
|
now=None,
|
|
) -> dict[str, Any]:
|
|
"""Mint one launch and produce everything needed to exec the client.
|
|
|
|
Returns the built servers, the per-launch config file path, the argv to
|
|
exec, and the shared trusted identity — without starting anything, so the
|
|
same preparation is testable and inspectable via ``--dry-run``.
|
|
"""
|
|
built = build_application_mcp_servers(
|
|
profile_by_namespace,
|
|
client_type=client_type,
|
|
namespaces=namespaces,
|
|
config_path=config_path,
|
|
fleet_run_id=fleet_run_id,
|
|
session_id=session_id,
|
|
launch_nonce=launch_nonce,
|
|
now=now,
|
|
)
|
|
# Fail before writing anything if this client has no runnable path.
|
|
argv_probe = build_client_launch_argv(
|
|
client_type, "<pending>", extra_args=extra_args
|
|
)
|
|
launch_config_path = write_launch_config(
|
|
built["mcpServers"], directory=config_directory
|
|
)
|
|
argv = build_client_launch_argv(
|
|
client_type, launch_config_path, extra_args=extra_args
|
|
)
|
|
proof = collect_instance_ids_from_mcp_servers(
|
|
built["mcpServers"], namespaces=built["namespaces"]
|
|
)
|
|
return {
|
|
"client_type": built["client_type"],
|
|
"client_instance_id": built["client_instance_id"],
|
|
"namespaces": built["namespaces"],
|
|
"excluded_namespaces": built["excluded_namespaces"],
|
|
"project_scoped": built["project_scoped"],
|
|
"launch_config_path": launch_config_path,
|
|
"argv": argv,
|
|
"argv_template": argv_probe,
|
|
"mcpServers": built["mcpServers"],
|
|
"launch": built["launch"],
|
|
"shared_single_trusted_id": proof["shared_single_trusted_id"],
|
|
"attribution_proof": proof,
|
|
}
|
|
|
|
|
|
def _parse_profile_assignments(values: "Sequence[str]") -> dict[str, str]:
|
|
"""Parse ``namespace=profile`` CLI pairs into a mapping."""
|
|
profiles: dict[str, str] = {}
|
|
for raw in values or ():
|
|
text = str(raw)
|
|
if "=" not in text:
|
|
raise ValueError(
|
|
f"invalid --profile {text!r}; expected namespace=profile-name"
|
|
)
|
|
namespace, _, profile = text.partition("=")
|
|
namespace = namespace.strip().lower()
|
|
profile = profile.strip()
|
|
if not namespace or not profile:
|
|
raise ValueError(
|
|
f"invalid --profile {text!r}; expected namespace=profile-name"
|
|
)
|
|
if namespace in profiles:
|
|
raise ValueError(f"duplicate --profile entry for namespace {namespace!r}")
|
|
profiles[namespace] = profile
|
|
return profiles
|
|
|
|
|
|
def main(argv: "Sequence[str] | None" = None) -> int:
|
|
"""Runnable entry point: start a supported LLM client for one launch.
|
|
|
|
Example (project-scoped, three namespaces)::
|
|
|
|
python3 -m mcp_application_launcher \\
|
|
--client claude_code \\
|
|
--namespaces author,reviewer,merger \\
|
|
--profile author=prgs-author \\
|
|
--profile reviewer=prgs-reviewer \\
|
|
--profile merger=prgs-merger
|
|
"""
|
|
import argparse
|
|
import json
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="mcp_application_launcher",
|
|
description=(
|
|
"Start a supported LLM client with one trusted, per-launch "
|
|
"GITEA_MCP_CLIENT_INSTANCE shared by every MCP namespace worker."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--client",
|
|
default="claude_code",
|
|
help=f"client to launch; supported: {list(supported_launch_clients())}",
|
|
)
|
|
parser.add_argument(
|
|
"--namespaces",
|
|
default=None,
|
|
help=(
|
|
"comma-separated sanctioned namespace subset (e.g. "
|
|
"'author,reviewer,merger'); omit to launch all five"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--profile",
|
|
action="append",
|
|
default=[],
|
|
metavar="NAMESPACE=PROFILE",
|
|
help="profile for one namespace; repeat once per launched namespace",
|
|
)
|
|
parser.add_argument("--config-path", default=None, help="profiles.json path")
|
|
parser.add_argument("--fleet-run-id", default=None)
|
|
parser.add_argument("--session-id", default=None)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="print the launch plan as JSON and exit without starting anything",
|
|
)
|
|
parser.add_argument(
|
|
"client_args",
|
|
nargs=argparse.REMAINDER,
|
|
help="arguments forwarded to the client after '--'",
|
|
)
|
|
ns = parser.parse_args(list(argv) if argv is not None else None)
|
|
|
|
try:
|
|
profiles = _parse_profile_assignments(ns.profile)
|
|
requested = (
|
|
[part.strip() for part in ns.namespaces.split(",") if part.strip()]
|
|
if ns.namespaces
|
|
else None
|
|
)
|
|
resolved = resolve_launch_namespaces(requested)
|
|
# Profiles for namespaces that are not being launched are almost always
|
|
# a mistake (a stale five-namespace invocation), so refuse rather than
|
|
# silently ignore them.
|
|
stray = sorted(set(profiles) - set(resolved))
|
|
if stray:
|
|
raise ValueError(
|
|
f"--profile given for namespace(s) {stray} that this launch "
|
|
f"does not start; launched namespaces are {list(resolved)}"
|
|
)
|
|
forwarded = [a for a in (ns.client_args or []) if a != "--"]
|
|
prepared = prepare_application_launch(
|
|
profiles,
|
|
client_type=ns.client,
|
|
namespaces=requested,
|
|
config_path=ns.config_path,
|
|
fleet_run_id=ns.fleet_run_id,
|
|
session_id=ns.session_id,
|
|
extra_args=forwarded,
|
|
)
|
|
except ValueError as exc:
|
|
parser.error(str(exc))
|
|
return 2 # pragma: no cover - argparse.error raises SystemExit
|
|
|
|
if ns.dry_run:
|
|
plan = {
|
|
key: prepared[key]
|
|
for key in (
|
|
"client_type",
|
|
"client_instance_id",
|
|
"namespaces",
|
|
"excluded_namespaces",
|
|
"project_scoped",
|
|
"launch_config_path",
|
|
"argv",
|
|
"shared_single_trusted_id",
|
|
)
|
|
}
|
|
print(json.dumps(plan, indent=2))
|
|
return 0
|
|
|
|
argv_to_exec = prepared["argv"]
|
|
os.execvp(argv_to_exec[0], argv_to_exec)
|
|
return 0 # pragma: no cover - execvp does not return
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover - process entry point
|
|
raise SystemExit(main())
|