feat(launcher): trusted client-instance identity for project-scoped launches

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]>
This commit is contained in:
2026-07-31 03:15:53 -05:00
co-authored by Claude Opus 4.8
parent 32ab839289
commit 7e19079b5c
6 changed files with 1185 additions and 25 deletions
+90 -2
View File
@@ -34,8 +34,10 @@ namespaces.
1. Multiple active instances **may** share the same `client_type`.
2. Every application launch receives a **distinct** `client_instance_id`.
3. All five namespace workers of one launch report the **same**
`client_instance_id`.
3. All namespace workers of one launch report the **same**
`client_instance_id`. For a whole-fleet launch that is five workers; for a
project-scoped launch it is exactly the namespaces that launch started
(#985).
4. Each namespace worker has a **distinct** `worker_identity`, process
identity, generation, and PID.
5. Instance identity is **never** inferred from PID proximity, timestamps, or
@@ -46,6 +48,92 @@ namespaces.
This deliberately **replaces** any permanent `exactly_one_per_profile` fleet
model (#949 assumption) as the operating rule for multi-instance fleets.
## Project-scoped launches and the runnable entry point (#985)
Not every project exposes all five namespaces. A project-scoped configuration
such as Weekly Briefings intentionally has **author, reviewer, and merger
only**, and has no controller or reconciler profile to supply. Requiring all
five would force operators to invent profiles that must not exist, so the
launcher accepts an explicitly validated subset.
### Running a launch
```bash
python3 -m mcp_application_launcher \
--client claude_code \
--namespaces author,reviewer,merger \
--profile author=prgs-author \
--profile reviewer=prgs-reviewer \
--profile merger=prgs-merger
```
That mints one trusted `client_instance_id`, writes a per-launch `mcpServers`
config, and executes:
```text
claude --mcp-config <per-launch.json> --strict-mcp-config
```
Add `--dry-run` to print the plan (namespaces, excluded namespaces, minted ID,
argv, config path) as JSON and start nothing. Omit `--namespaces` to launch all
five exactly as before. Arguments after the flags are forwarded to the client.
Other supported clients register one entry in
`mcp_application_launcher.CLIENT_LAUNCH_SPECS`; because the argv builder only
ever receives a config this module wrote, every client necessarily goes through
the same mint-once/propagate-to-all mechanism.
### Why the config is per-launch and never `.mcp.json`
Persisting a trusted ID into a shared, reused `.mcp.json` would give two
concurrent sessions **the same** trusted identity — precisely the reuse case
the duplicate gate must reject. The launcher therefore writes a fresh
owner-readable-only (`0600`) config per launch. Do not commit one, and do not
copy a minted `GITEA_MCP_CLIENT_INSTANCE` into any checked-in configuration.
### Provenance sealing
The `inst-…` format is public and reproducible, so format alone cannot
establish trust: anyone able to set one environment variable could otherwise
hand-write a valid-looking ID and be believed. Trust therefore requires **both**
the format and `GITEA_MCP_INSTANCE_PROVENANCE=trusted_launcher`, which only the
launcher writes. A well-formed but unsealed identity is classified
`unsealed_launcher` and **fails closed** — it is still reported for diagnosis,
but never authorizes trusted attribution. Manually asserted trust is not
possible.
### Migration and coordinated relaunch
Existing configurations that predate this work set no instance key at all, so
their workers register under `legacy-pid-*` (`legacy_incomplete`) and remain
mutually indistinguishable when two launches share a profile.
Migrating is a **coordinated relaunch**, not an in-place edit — a running
worker cannot acquire an identity it was not started with:
1. Stop every LLM client currently running Gitea MCP workers. A single
surviving legacy cohort keeps the fleet ambiguous.
2. Relaunch each application through the command above.
3. Repeat per application. Concurrent launches are expected and safe: each
receives its own trusted ID.
### Post-launch verification
* `gitea_get_runtime_context``provenance_assessment.attachment` should show
an `inst-…` `client_instance_id` with
`instance_id_provenance: trusted_launcher`, not `legacy-pid-*` /
`legacy_incomplete`.
* Every namespace of the same launch must report that **same** ID; two
different launches must report different IDs.
* `gitea_resolve_task_capability` should return
`exact_safe_next_action: "None; ready for operations."` with no
`blocker_kind`. A `runtime_reconnect_required` naming duplicate PIDs per
profile means at least one cohort is still on legacy identity, or a second
cohort is genuinely running.
* `mcp_application_launcher.collect_instance_ids_from_mcp_servers(servers,
namespaces=[...])` returns `shared_single_trusted_id` for a built config, and
reports `missing_servers` when an expected namespace entry is absent.
## How five workers join one instance
1. The host starts one application instance (for example one Codex session).