workstation: per-user playwright browser MCP for all users, reproducible from git
Viktor asked that the playwright browser MCP be available for every devvm user
in every directory, with each user running their own server and multiple
concurrent sessions per user.
Before this, playwright was hand-set-up per user (~/.config/systemd/user/
playwright-mcp.service on 8931/8932/8933) and only wizard was actually wired —
emo's and anca's servers ran but their ~/.claude.json had no playwright entry,
so their Claude never connected. None of it was reproducible from git (units,
refresh script, and the Vault snapshot token lived only in user homes), so a
devvm rebuild would silently lose it.
This makes it reproducible and fixes the unwired users:
- roster_engine.py: sticky per-user PLAYWRIGHT_PORT (PLAYWRIGHT_BASE_PORT=8931,
allocated for every roster user incl. the admin), emitted in the derive JSON.
- scripts/workstation/playwright/: system-level TEMPLATE units
(playwright-mcp@.service + playwright-snapshot-refresh@.{service,timer},
User=%i — system manager, so no systemd --user / linger) + the refresh script.
@playwright/mcp pinned to 0.0.76 (avoids the @latest silent-fleet-roll
footgun, same rationale as T3_PIN).
- setup-devvm.sh: install the templates + script (9e); stage the chrome-service
snapshot bearer token from Vault to a root file (8c) — the hourly root
reconcile has no Vault token, mirrors the Claude OAuth staging in 8a.
- t3-provision-users.sh: install_playwright() (ALL tiers incl. admin) writes
PLAYWRIGHT_PORT, seeds the token if-absent, wires the user-scope ~/.claude.json
by running `claude mcp add` AS the user (clobber-proof + if-absent, so it fixes
existing/new/admin without rewriting a populated config), and enable --now's the
instances (idempotent, never restarts a running server). Also hardened the
section-1 *.env scan to skip the new playwright-*.env files (no T3_PORT -> grep
no-match would abort under set -e -o pipefail).
- Docs: chrome-service-snapshot runbook (new Provisioning section + system-unit
commands), multi-tenancy.md, and the 2026-06-07 plan Task 2.3.
Supersedes the hand-made per-user --user units (one-time idle-gated migration to
follow on the live host).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
eb47eb1d10
commit
0a6ed4b2fe
11 changed files with 373 additions and 29 deletions
|
|
@ -21,6 +21,12 @@ from typing import Iterable
|
|||
import yaml
|
||||
|
||||
BASE_PORT = 3773
|
||||
# Per-user playwright-mcp HTTP port (the browser MCP each user's Claude sessions
|
||||
# connect to). Distinct range from T3_PORT, allocated for EVERY roster user incl.
|
||||
# the admin (wizard is listed). Sticky from existing, so the live in-session
|
||||
# assignments (wizard 8931, emo 8932, ancamilea 8933) are preserved across
|
||||
# reconciles once seeded; a fresh box allocates from 8931 in sorted order.
|
||||
PLAYWRIGHT_BASE_PORT = 8931
|
||||
VALID_TIERS = ("admin", "power-user", "namespace-owner")
|
||||
# single - ~/code IS the locked infra clone (the original non-admin layout)
|
||||
# workspace - ~/code is a plain directory of per-project clones; the locked
|
||||
|
|
@ -82,6 +88,7 @@ class DesiredState:
|
|||
ttyd_user_map: str
|
||||
dispatch: dict[str, dict]
|
||||
ports: dict[str, int]
|
||||
playwright_ports: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -203,13 +210,18 @@ def has_blocking_errors(issues: list[ValidationIssue]) -> bool:
|
|||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allocate_ports(roster: Roster, existing_ports: dict[str, int]) -> dict[str, int]:
|
||||
def _allocate_ports(
|
||||
roster: Roster, existing_ports: dict[str, int], base: int = BASE_PORT
|
||||
) -> dict[str, int]:
|
||||
"""Sticky port allocation: keep every roster user's existing port, then assign
|
||||
each new user the next free port from `base`. Used for both T3_PORT (base 3773)
|
||||
and the per-user playwright-mcp port (base 8932)."""
|
||||
ports = {u: existing_ports[u] for u in roster.users if u in existing_ports}
|
||||
used = set(ports.values())
|
||||
for os_user in sorted(roster.users):
|
||||
if os_user in ports:
|
||||
continue
|
||||
candidate = BASE_PORT
|
||||
candidate = base
|
||||
while candidate in used:
|
||||
candidate += 1
|
||||
ports[os_user] = candidate
|
||||
|
|
@ -224,9 +236,14 @@ _TTYD_MAP_HEADER = (
|
|||
|
||||
|
||||
def derive_desired_state(
|
||||
roster: Roster, existing_ports: dict[str, int]
|
||||
roster: Roster,
|
||||
existing_ports: dict[str, int],
|
||||
existing_playwright_ports: dict[str, int] | None = None,
|
||||
) -> DesiredState:
|
||||
ports = _allocate_ports(roster, existing_ports)
|
||||
playwright_ports = _allocate_ports(
|
||||
roster, existing_playwright_ports or {}, base=PLAYWRIGHT_BASE_PORT
|
||||
)
|
||||
ordered = sorted(roster.users.values(), key=lambda u: ports[u.os_user])
|
||||
ttyd_lines = [f"{u.authentik_user}={u.os_user}" for u in ordered]
|
||||
ttyd_user_map = _TTYD_MAP_HEADER + "\n".join(ttyd_lines) + "\n"
|
||||
|
|
@ -246,7 +263,7 @@ def derive_desired_state(
|
|||
)
|
||||
for u in roster.users.values()
|
||||
}
|
||||
return DesiredState(accounts, ttyd_user_map, dispatch, ports)
|
||||
return DesiredState(accounts, ttyd_user_map, dispatch, ports, playwright_ports)
|
||||
|
||||
|
||||
def groups_to_add(desired: Iterable[str], current: Iterable[str]) -> list[str]:
|
||||
|
|
@ -303,6 +320,7 @@ def _desired_state_to_dict(ds: DesiredState) -> dict:
|
|||
"ttyd_user_map": ds.ttyd_user_map,
|
||||
"dispatch": ds.dispatch,
|
||||
"ports": ds.ports,
|
||||
"playwright_ports": ds.playwright_ports,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -318,7 +336,11 @@ def _main(argv: list[str]) -> int:
|
|||
pv.add_argument("--k8s-users-json", required=True, help="JSON map {k8s_user: tier}")
|
||||
pd = sub.add_parser("derive", help="emit desired state as JSON")
|
||||
pd.add_argument("--roster", required=True)
|
||||
pd.add_argument("--ports-json", required=True, help="JSON map {os_user: port}")
|
||||
pd.add_argument("--ports-json", required=True, help="JSON map {os_user: T3_PORT}")
|
||||
pd.add_argument(
|
||||
"--playwright-ports-json",
|
||||
help="JSON map {os_user: PLAYWRIGHT_PORT} (optional; sticky allocation)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
roster = load_roster_file(args.roster)
|
||||
|
|
@ -329,7 +351,12 @@ def _main(argv: list[str]) -> int:
|
|||
print(f"{issue.severity.upper()}: {issue.message}", file=sys.stderr)
|
||||
return 1 if has_blocking_errors(issues) else 0
|
||||
with open(args.ports_json, encoding="utf-8") as fh:
|
||||
desired = derive_desired_state(roster, json.load(fh))
|
||||
existing_ports = json.load(fh)
|
||||
existing_playwright_ports = {}
|
||||
if args.playwright_ports_json:
|
||||
with open(args.playwright_ports_json, encoding="utf-8") as fh:
|
||||
existing_playwright_ports = json.load(fh)
|
||||
desired = derive_desired_state(roster, existing_ports, existing_playwright_ports)
|
||||
json.dump(_desired_state_to_dict(desired), sys.stdout, indent=2, sort_keys=True)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue