fix(sessions): keep named-pipe socket paths inside the macOS sun_path limit (NEXT-01)

The pipe name mxaccess-gateway-{pid}-session-{32hex} plus .NET's
CoreFxPipe_ prefix overflowed the 104-byte Unix-domain-socket path limit
under the default per-user macOS TMPDIR (~49 chars), so every test that
opened a real pipe threw ArgumentOutOfRangeException at pipe creation
unless TMPDIR=/tmp was exported. Rename to mxgw-{pid}-{sessionUid} (the
session guid hex without the session- prefix; worst-case 43 chars) and
shorten the three test-fixture names the same way. Uniqueness is
unchanged: gateway pid + full session guid. The worker receives the pipe
name via its launch command line, so mixed Server/Worker deploy SHAs are
unaffected. Docs updated in the same change (gateway.md,
GatewayProcessDesign, GatewayConfiguration, Sessions, CLAUDE.md); new
regression test pins the format and the length budget.

Verified: SessionManagerTests 39/39; SessionWorkerClientFactory,
GatewayEndToEndFakeWorkerSmoke, WorkerClient, and ReconnectReplay suites
33/33 under the default macOS TMPDIR — this also retires the
previously-misdiagnosed 'macOS pipe-timeout test failures': they were
this path-length throw, not a timeout-message defect.
This commit is contained in:
Joseph Doherty
2026-08-10 05:54:11 -04:00
parent 0152180929
commit 8769ee9765
14 changed files with 769 additions and 15 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ The architecture is a two-process design — read `gateway.md` before making str
- **Gateway** (`src/ZB.MOM.WW.MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. The Galaxy-browse implementation comes from the shared **`ZB.MOM.WW.GalaxyRepository`** package (`AddZbGalaxyRepository`/`MapZbGalaxyRepository`), not inline code; mxaccessgw adds `GatewayBrowseScopeProvider` (per-key browse-subtree scoping) and a host-side dashboard summary projector. See `A2-galaxyrepository-adoption-handoff.md`. **Never instantiates MXAccess COM directly.** - **Gateway** (`src/ZB.MOM.WW.MxGateway.Server`, .NET 10, x64): ASP.NET Core gRPC server. Owns the public API, sessions, auth, the Blazor dashboard, and the Galaxy Repository SQL browse RPCs. The Galaxy-browse implementation comes from the shared **`ZB.MOM.WW.GalaxyRepository`** package (`AddZbGalaxyRepository`/`MapZbGalaxyRepository`), not inline code; mxaccessgw adds `GatewayBrowseScopeProvider` (per-key browse-subtree scoping) and a host-side dashboard summary projector. See `A2-galaxyrepository-adoption-handoff.md`. **Never instantiates MXAccess COM directly.**
- **Worker** (`src/ZB.MOM.WW.MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf. - **Worker** (`src/ZB.MOM.WW.MxGateway.Worker`, .NET Framework 4.8, **x86**): one process per session. Owns one MXAccess COM instance on a dedicated STA, pumps Windows messages, and converts COM events to protobuf.
- **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxaccess-gateway-{gatewayPid}-{sessionId}`) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack. - **IPC**: gateway↔worker uses one bidirectional named pipe per worker (`mxgw-{gatewayPid}-{sessionUid}` — kept short so the macOS/Linux test matrix's Unix-domain-socket path fits the 104-byte macOS `sun_path` limit) with length-prefixed `WorkerEnvelope` protobuf frames. Gateway hosts the pipe server and launches the worker. **gRPC is not used inside the worker** — .NET Framework 4.8 doesn't have a first-class gRPC stack.
- **Contracts** (`src/ZB.MOM.WW.MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`. Note `galaxy_repository.proto` is intentionally kept here as the generation source for the language clients even though the gateway server consumes the wire-identical Galaxy types from the `ZB.MOM.WW.GalaxyRepository` package — it is not dead code; deleting it breaks all five clients. - **Contracts** (`src/ZB.MOM.WW.MxGateway.Contracts`): multi-targets `net10.0;net48` and owns the `.proto` files (`mxaccess_gateway.proto`, `mxaccess_worker.proto`, `galaxy_repository.proto`). All other projects consume the generated types from here. Do not hand-edit anything under `Generated/`. Note `galaxy_repository.proto` is intentionally kept here as the generation source for the language clients even though the gateway server consumes the wire-identical Galaxy types from the `ZB.MOM.WW.GalaxyRepository` package — it is not dead code; deleting it breaks all five clients.
The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough. The worker must do all MXAccess COM calls on its dedicated STA thread, and the STA loop must pump Windows messages (`MsgWaitForMultipleObjectsEx` + `PeekMessage`/`DispatchMessage`) so MXAccess events deliver. A plain blocking queue on an STA is not enough.
+1 -1
View File
@@ -663,7 +663,7 @@ See each client README for the as-built behavior.
Transport security here applies only to the public gRPC channel. The Transport security here applies only to the public gRPC channel. The
gateway↔worker link is a per-session **named pipe** gateway↔worker link is a per-session **named pipe**
(`mxaccess-gateway-{gatewayPid}-{sessionId}`), not a network socket. It is not (`mxgw-{gatewayPid}-{sessionUid}`), not a network socket. It is not
TLS-encrypted and does not need to be: it never leaves the local Windows host and TLS-encrypted and does not need to be: it never leaves the local Windows host and
is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md). is secured by the OS pipe ACL. See [Worker Frame Protocol](./WorkerFrameProtocol.md).
+6 -1
View File
@@ -418,9 +418,14 @@ The gateway creates the pipe server before launching the worker.
Pipe name: Pipe name:
```text ```text
mxaccess-gateway-{gatewayProcessId}-{sessionId} mxgw-{gatewayProcessId}-{sessionUid}
``` ```
`sessionUid` is the session id's guid hex without the `session-` prefix. The
short form keeps the Unix-domain-socket path .NET uses for named pipes on
macOS/Linux (`$TMPDIR/CoreFxPipe_{name}`) inside the 104-byte macOS `sun_path`
limit under the default per-user `TMPDIR`.
Message framing: Message framing:
```text ```text
+1 -1
View File
@@ -14,7 +14,7 @@ All four interfaces (`ISessionManager`, `ISessionRegistry`, `ISessionWorkerClien
`GatewaySession` is a sealed class that holds the identity, configured timeouts, worker client reference, and current `SessionState` for one session. State is protected by a private `_syncRoot` lock so that property reads and transitions are observed atomically by concurrent gRPC calls and the lease sweeper. `GatewaySession` is a sealed class that holds the identity, configured timeouts, worker client reference, and current `SessionState` for one session. State is protected by a private `_syncRoot` lock so that property reads and transitions are observed atomically by concurrent gRPC calls and the lease sweeper.
The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxaccess-gateway-{ProcessId}-{SessionId}`. Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed. The session id is an opaque string in the form `session-{guid:N}` and the per-session pipe name is `mxgw-{ProcessId}-{guid:N}` (the same guid hex, without the `session-` prefix). Encoding the gateway PID into the pipe name avoids collisions when an old gateway process leaks pipes that the OS has not yet reclaimed. The name is kept short because .NET named pipes on Unix-like hosts are Unix domain sockets at `$TMPDIR/CoreFxPipe_{name}`, and macOS caps that path at 104 bytes while its default per-user `TMPDIR` is already ~49 — the old `mxaccess-gateway-{pid}-{sessionId}` form overflowed it and broke the fake-worker/e2e tests on macOS.
`SessionState` itself is the protobuf-generated enum from `ZB.MOM.WW.MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire. `SessionState` itself is the protobuf-generated enum from `ZB.MOM.WW.MxGateway.Contracts.Proto`, so it is shared between the gateway and clients on the wire.
@@ -0,0 +1,261 @@
# Follow-Ups: windev Redeploy, LDAP Test Fixtures, Runner Hygiene — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development
> (opus implementers; controller verifies ops evidence; final review pass).
**Goal:** Close the five items surfaced by the 2026-08-07 live-actions cycle: repair windev's
crash-looping gateway and finish the deferred SEC-36 dashboard verification (NEXT-07), fix the
DashboardLdapLiveTests fixture drift (NEXT-06), resolve the unexpected macOS instance runner,
harden runner-1's plaintext registration token, and verify the cargo Bearer fix.
**Architecture:** Three independent live streams (windev serial: 1→2→3; repo test fix: 4;
Gitea/runner hygiene: 5, 6) run concurrently; task 7 closes out docs/trackers. No contract,
gateway-logic, or client changes — one test-file edit (Task 4) plus live ops plus docs.
**Tech Stack:** SSH + PowerShell `-EncodedCommand` (windev 10.100.0.48), SSH + docker compose
(10.100.0.35), Gitea admin API (`gitea.dohertylan.com`, token via `~/.zshenv` `GITEA_TOKEN`),
NSSM, xUnit live-LDAP suite, GLAuth at `10.100.0.35:3893`.
---
## Preflight facts (verified before planning)
- Unpushed local mxaccessgw commits: `0566716`, `9760497`, `5b153da`, `41e8648` (all docs-only).
**`origin/main` = `a346d51`** — contains all current code, so windev can build from
`origin/main` without any push.
- windev (`10.100.0.48`): NSSM service `MxAccessGw`; deployed Server build of 2026-06-25
(Auth 0.1.2.0, supports auth-DB schema 2) crash-loops on
`C:\ProgramData\MxGateway\gateway-auth.db` migrated to schema 3 on 2026-07-15
(`AuthStoreMigrationException`, ~3.8k10k Hosting-failed events/day). The NEW LDAP secret is
already staged as the 10th `AppEnvironmentExtra` entry (SEC-36 Task 3) — preserve it.
- `DashboardLdapLiveTests.cs` (`src/ZB.MOM.WW.MxGateway.IntegrationTests/`): uses
`admin`/`admin123` (3 tests) and `readonly`/`readonly123`. Directory reality
(`scadaproj/infra/glauth/config.toml`): `admin` exists, password is the standard dev test
password (`password`, hash `5e884898…42d8` — same as `multi-role`), and IS in GwAdmin
(othergroups `[5610, 5701]`); `readonly` does not exist; `gw-viewer` (primarygroup 5611 =
GwReader, NOT GwAdmin) is the natural not-an-admin fixture. Test binds
`MxGateway:Ldap` from `appsettings.json` (**`Server: localhost`**) + env overrides — so the
live run needs `MxGateway__Ldap__Server=10.100.0.35` as well as
`MxGateway__Ldap__ServiceAccountPassword` (from Mac user-secrets, never printed).
- Gitea instance runners (`GET /api/v1/admin/actions/runners`): id 1 `gitea-runner` (cap 4),
id 4 `macos-local-Josephs-MacBook-Pro` (**unexpected, online, labels overlap
ubuntu-latest**), id 5 `gitea-runner-2` (cap 2).
- `10.100.0.35:/opt/gitea/docker-compose.yml` (+ `docker-compose.yml.bak-tst30`): runner-1's
registration token inline in plaintext env, file world-readable. runner-2 uses
`GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /run/secrets/runner_token` ← 0600
`/opt/gitea/runner_token`. runner-1 data volume `/opt/gitea/runner:/data` (its `.runner`
credential persists — the registration env is only needed for first registration).
- Cargo Bearer fix already applied (`~/.zshenv`, backup `~/.zshenv.bak-cli39`) and documented
(`docs/ClientPackaging.md`, commit `5b153da`). Task 7 verifies; no further action expected.
## Secret hygiene (binding, all tasks)
- Never print the LDAP service-account password, `GITEA_TOKEN`, cargo token, runner
registration tokens, or API keys — not in commands, logs, commits, or reports. Read the LDAP
password from `dotnet user-secrets list` into an env var without echoing
(e.g. `export MxGateway__Ldap__ServiceAccountPassword="$(dotnet user-secrets list --project src/ZB.MOM.WW.MxGateway.Server | awk -F' = ' '/ServiceAccountPassword/ {print $2}')"`).
- Documented dev **test users** (`multi-role`/`password`, `admin`/`password`,
`gw-viewer`/`password`) are NOT secrets — glauth.md publishes them; fine in code/commits.
- SSH→windev PowerShell: always `powershell -NoProfile -EncodedCommand <base64-UTF16LE>`;
never put secrets inside EncodedCommand blobs or argv.
---
### Task 1: NEXT-07 — Recon windev deployment layout + schema support
**Classification:** standard — read-only recon, but its output gates a service redeploy
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4, Task 5, Task 6
**Files:** none edited. SSH recon on `10.100.0.48` + repo/scadaproj reads on the Mac.
Determine everything Task 2 needs, and confirm the fresh-deploy path is safe:
1. `nssm get MxAccessGw Application`, `AppDirectory`, `AppParameters`,
`AppEnvironmentExtra` (count entries; do NOT print values of secret-bearing entries —
names only).
2. Inventory the deployed dir: path, `ZB.MOM.WW.MxGateway.Server.exe` timestamp, whether
`appsettings.json`/`appsettings.Production.json` in the deploy dir differ from repo
`origin/main` (diff; windev-specific config must survive the redeploy).
3. Confirm build feasibility on windev: `dotnet --list-sdks` (need 10.x), locate an existing
mxaccessgw checkout/worktree (CI uses `scripts/ci/windev-worker-ci.ps1` — find its
worktree path) or pick a fresh clone location. Confirm `git fetch` reaches `origin/main`
= `a346d514dd24e775640e5667aa7cd8e561fec68a`.
4. Confirm current code supports auth-DB schema 3: find the auth-store supported-schema
constant (ZB.MOM.WW.Auth packages — check the package version the Server at `origin/main`
references, and/or the migration code in the shared scadaproj libs) and state the
evidence. **If current code does NOT support schema 3, STOP — report, do not deploy.**
5. Gateway endpoints for verification: bound URLs/ports (from deployed config/env), dashboard
scheme (http vs https → cookie will be `MxGatewayDashboard` vs `__Host-…`).
6. Check what migrated the DB to schema 3 on 2026-07-15 (event log / file timestamps) — only
to confirm schema 3 is the shared-lib current version, not an anomaly.
**Step: report** all findings as structured text (no secrets); no changes, no commits.
### Task 2: NEXT-07 — Build current Server on windev and redeploy the service
**Classification:** high-risk — replaces a running (crash-looping) service's binaries
**Estimated implement time:** ~10 min
**Parallelizable with:** none (needs Task 1)
**Files:** none in repo. windev filesystem + NSSM only.
Using Task 1's facts:
1. On windev, fetch/checkout `origin/main` (`a346d51…`) in the build worktree/clone.
2. `dotnet publish src/ZB.MOM.WW.MxGateway.Server -c Release` (match deployed layout/RID from
Task 1; framework-dependent vs self-contained must match what NSSM `Application` points at).
3. Stop the service (`nssm stop MxAccessGw`), confirm process exited.
4. Backup: deployed dir → sibling `*.bak-next07` copy; copy
`C:\ProgramData\MxGateway\gateway-auth.db` (+ `-wal`/`-shm` if present) to
`gateway-auth.db.bak-next07`. **Never delete the live DB.**
5. Deploy publish output over the deploy dir, then restore any windev-specific config files
identified in Task 1 (do not clobber live overrides; NSSM env entries are untouched by
file copies but verify count unchanged after start).
6. `nssm start MxAccessGw`; verify: service state RUNNING and stable ≥60 s (no restart
cycle), Application event log shows clean host start and **zero new
`AuthStoreMigrationException` / `Hosting failed to start`** after the start timestamp,
bound port answers (e.g. dashboard root or health endpoint returns HTTP).
7. Rollback if unhealthy: stop, restore `*.bak-next07` dir, start, report.
**Step: report** deployed SHA, verification evidence, backup paths. No repo commits.
### Task 3: SEC-36 deferred verification + NEXT-07/runbook closeout
**Classification:** standard
**Estimated implement time:** ~6 min
**Parallelizable with:** none (needs Task 2)
**Files:**
- Modify: `docs/runbooks/SEC-36-ldap-credential-rotation.md` (Correction 3 — mark the
deferred dashboard check done, dated)
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (NEXT-07 →
resolved 2026-08-07, evidence one-liner)
1. Complete SEC-36 step 4 against the repaired windev gateway: log in to the dashboard as
`multi-role`/`password` end-to-end. Preferred: `curl` flow — GET `/login` (capture
antiforgery token + cookie), POST credentials, expect success redirect + auth cookie
(name per Task 1 scheme). If the login page resists scripting (Blazor circuit), report
exactly why and fall back to asserting a fresh `DashboardLdapLiveTests` green run
(Task 4) plus windev log evidence of successful LDAP bind on a manual attempt.
2. Update the two docs; commit locally (`docs(sec-36,next-07): …`), do NOT push.
### Task 4: NEXT-06 — Fix DashboardLdapLiveTests fixtures to match the shared directory
**Classification:** small — one test file, but must go green against live GLAuth
**Estimated implement time:** ~6 min
**Parallelizable with:** Task 1, Task 5, Task 6
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs`
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (NEXT-06 →
resolved)
- Possibly modify: `docs/GatewayTesting.md` (live-LDAP opt-in row: document the
`MxGateway__Ldap__Server` override needed when GLAuth is not localhost)
1. Read `DashboardAuthenticator` first: confirm a user who binds successfully but maps to no
role yields `Succeeded == false` (drives the gw-viewer fixture).
2. Fix fixtures: `admin`/`admin123``admin`/`password` (positive + wrong-password +
unreachable tests); `readonly`/`readonly123``gw-viewer`/`password` (exercises
user-binds-but-lacks-GwAdmin; keep the no-password-leak assertion, updating the asserted
literal). Update XML doc comments to match. Keep MXAccess-repo style rules
(TreatWarningsAsErrors).
3. Build: `dotnet build src/ZB.MOM.WW.MxGateway.IntegrationTests` (macOS OK — net10.0).
4. Live run (env only, never echo the password):
`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1 MxGateway__Ldap__Server=10.100.0.35 MxGateway__Ldap__ServiceAccountPassword=<from user-secrets> dotnet test … --filter FullyQualifiedName~DashboardLdapLiveTests`
→ expect **5/5 passed** (this is also positive live proof of the SEC-36 service-account
bind).
5. Update tracker row (+ GatewayTesting.md if the Server-override note is missing); commit
locally (`test(ldap): …`), do NOT push.
### Task 5: Resolve the unexpected macOS instance runner (id 4)
**Classification:** standard — evidence-gated removal of a live runner registration
**Estimated implement time:** ~6 min
**Parallelizable with:** Task 1, Task 4, Task 6
**Files:**
- Modify: `docs/runbooks/TST-30-second-ci-runner.md` (the Correction paragraph mentions
"id 4 — an unrelated local macOS runner" — update to final state)
1. Evidence, local: `pgrep -fl act_runner`, `launchctl list | grep -i act`,
`brew services list | grep -i act`, look for `~/.runner`/act_runner config dirs. Evidence,
Gitea (token from `~/.zshenv`, never printed): runner detail for id 4 (labels, last
online), and whether any recent runs' jobs report `runner_id == 4`
(`GET /repos/{owner}/{repo}/actions/runs?…``…/runs/{id}/jobs` for both `mxaccessgw`
and `lmxopcua` recent runs).
2. Decision rule: the runner advertises ubuntu labels from a macOS host, so it can steal
Linux container jobs → **remove it** unless evidence shows it deliberately serves jobs
the docker runners cannot (none expected). Removal = stop the local act_runner process
AND disable its autostart (launchd/brew), then `DELETE /api/v1/admin/actions/runners/4`.
Keep the local config file (renamed `*.disabled-2026-08-07`) so re-registering with
mac-specific labels stays easy; note the re-registration recipe in the runbook edit.
3. Verify: admin runner list shows only ids 1 and 5, both online; no act_runner process
locally; a `pgrep` after 60 s still empty (nothing respawned).
4. Update the TST-30 runbook correction paragraph; commit locally, do NOT push.
### Task 6: Harden runner-1's registration token on 10.100.0.35
**Classification:** standard — touches the live CI stack's compose file
**Estimated implement time:** ~7 min
**Parallelizable with:** Task 1, Task 4, Task 5
**Files:** none in repo (host `/opt/gitea/` only; runbook note lands in Task 7 if needed).
1. Preconditions on the host: confirm runner-1's `/data/.runner` exists in its volume
(registration credential persists → the registration env var is no longer needed);
confirm both runners idle (no `act_runner`-spawned job containers, no in-progress runs
via API) before recreating.
2. Edit `/opt/gitea/docker-compose.yml` (backup first → `docker-compose.yml.bak-tst30b`):
replace runner-1's inline `GITEA_RUNNER_REGISTRATION_TOKEN: <plaintext>` with the same
`_FILE`/secrets pattern runner-2 uses (`/opt/gitea/runner_token`, 0600). Do NOT touch the
`gitea` service definition.
3. `docker compose up -d --no-deps` the runner-1 service only; verify it comes back online
in the admin runner list and its `.runner` identity is unchanged (still id 1).
4. Tighten perms: `chmod 600 /opt/gitea/docker-compose.yml docker-compose.yml.bak-tst30 docker-compose.yml.bak-tst30b`
(verify compose stack still operable by the deploy user).
5. Rotate the leaked registration token if the deployment allows:
`docker exec … gitea actions generate-runner-token` (or admin API) — if Gitea offers no
invalidation of the old value, say so explicitly in the report (residual risk: LAN actor
could register a rogue runner until rotation) rather than claiming it rotated.
6. Verify CI still works: trigger nothing; just confirm both runners online and the token
file perms; a real push lands naturally later. Report evidence.
### Task 7: Closeout — cargo Bearer verification, docs/tracker sync, commits
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs Tasks 3, 4, 5, 6)
**Files:**
- Modify: `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md` (final
state of NEXT-06/NEXT-07 rows if Tasks 3/4 left anything)
- Possibly modify: `docs/GatewayTesting.md` / TST-30 runbook (runner topology now ids 1+5
only; token-hardening note)
1. Cargo Bearer verification (no printing): assert `~/.zshenv` line matches
`CARGO_REGISTRIES_DOHERTJ2_GITEA_TOKEN="Bearer …"` via `grep -c`, confirm
`docs/ClientPackaging.md` note present (commit `5b153da`); check no other credential
location (CI secrets, windev profiles) publishes to cargo — expected none.
2. Sweep: every doc touched this cycle consistent (runbooks, trackers, GatewayTesting.md);
`git grep` for stale phrases ("crash-loop… pending", "id 4", "admin123") and fix.
3. Commit remaining doc changes locally; do NOT push. List the full unpushed stack in the
report.
---
## Out of scope
- Pushing any mxaccessgw commits (user decides; stack listed at closeout).
- The five next-cycle candidate findings other than NEXT-06/NEXT-07.
- Auth-DB restore path for windev (fresh deploy chosen — preserves schema-3 data).
- `ci.yml` changes (labels, concurrency groups).
## Dependency graph
```
{1} → 2 → 3 ┐
{4} ├→ 7
{5} │
{6} ─────────┘
```
@@ -0,0 +1,13 @@
{
"planPath": "docs/plans/2026-08-07-followups-windev-ldapfixtures-runners.md",
"tasks": [
{"id": 1, "subject": "Task 1: NEXT-07 — Recon windev deployment layout + schema support", "status": "completed"},
{"id": 2, "subject": "Task 2: NEXT-07 — Build current Server on windev and redeploy the service", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3: SEC-36 deferred verification + NEXT-07/runbook closeout", "status": "completed", "blockedBy": [2]},
{"id": 4, "subject": "Task 4: NEXT-06 — Fix DashboardLdapLiveTests fixtures to match the shared directory", "status": "completed"},
{"id": 5, "subject": "Task 5: Resolve the unexpected macOS instance runner (id 4)", "status": "completed"},
{"id": 6, "subject": "Task 6: Harden runner-1's registration token on 10.100.0.35", "status": "completed"},
{"id": 7, "subject": "Task 7: Closeout — cargo Bearer verification, docs/tracker sync, commits", "status": "completed", "blockedBy": [3, 4, 5, 6]}
],
"lastUpdated": "2026-08-07 (all tasks executed; SEC-36 verification done during Task 2's foreground smoke test; 8 commits local on main, not pushed; one pending operator action: Gitea registration-token UI reset)"
}
@@ -0,0 +1,420 @@
# Live Actions: SEC-36 Rotation, TST-30 Second Runner, Client Publish — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task (or subagent-driven-development in-session).
**Goal:** Execute the three repo-complete-but-live-pending operator actions: rotate the dev GLAuth service-account credential (SEC-36), register a second Gitea Actions runner (TST-30), and publish the five client packages at 0.2.0 (Java 0.2.1).
**Architecture:** Three independent workstreams executed by subagents. SEC-36 is a strictly ordered cutover (pre-stage hosts → flip GLAuth → verify → finalize) with secret-hygiene rules. TST-30 is infra work on docker host 10.100.0.35 plus a concurrency verification. Publish runs the existing guarded `pack-clients.ps1 -Publish` + `tag-go-module.ps1` locally on macOS.
**Tech Stack:** ssh (BatchMode works to 10.100.0.35 and 10.100.0.48), PowerShell/nssm on windev, docker compose on 10.100.0.35, Gitea API (`~/.zshenv` has admin-scoped `GITEA_USERNAME`/`GITEA_TOKEN`), pwsh 7 on macOS.
---
## Preflight facts (verified 2026-08-07 from this macOS box)
- `ssh 10.100.0.35` OK. GLAuth container is **`zb-shared-glauth`**, compose working dir **`/home/dohertj2/zb-glauth`** (NOT the runbook's `~/Desktop/scadaproj/infra/glauth` — that path does not exist on the host; the runbook must be corrected in Task 5). Runner container **`gitea-runner`**, compose working dir **`/opt/gitea`**.
- `ssh 10.100.0.48` (windev) OK; `powershell -NoProfile` works; `nssm` at `C:\Users\dohertj2\AppData\Local\Microsoft\WinGet\Links\nssm.exe`.
- `wonder-app-vd03` does NOT resolve from macOS — check it from windev (Task 2).
- Gitea API: token valid (`/api/v1/user` → 200), admin (`/api/v1/admin/users` → 200, `POST /api/v1/admin/actions/runners/registration-token` → 200).
- Local `~/Desktop/scadaproj/infra/glauth/config.toml` exists (14 `passsha256` entries) — the git source of truth.
- `pwsh` at `/usr/local/bin/pwsh`.
## Secret hygiene (SEC-36, binding for every task)
- The new plaintext password lives ONLY in `$SECRET_FILE = /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret` (chmod 600), created in Task 1 and shredded in Task 5.
- **Never echo/cat the plaintext to stdout, never put it in a commit, a repo file, a log line, or a command whose text is captured verbatim.** Always load it into a shell variable from the file (`val=$(cat "$SECRET_FILE")`) and pass it via stdin or remote-side expansion, never inline in an `ssh "...literal..."` string where avoidable.
- The `passsha256` hash MAY appear in `config.toml` commits — that is the established pattern (14 existing entries).
- The OLD password must never be printed either. Its only uses are: GLAuth keeps honoring it until Task 4, and the single old-bind-must-fail probe in Task 4.
---
### Task 1: SEC-36 — Generate secret, stage GLAuth config change (repo + host copy diff)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 6, Task 9
**Files:**
- Modify: `~/Desktop/scadaproj/infra/glauth/config.toml` (the `serviceaccount` user's `passsha256`) — DO NOT commit yet (Task 5 commits)
- Create: `$SECRET_FILE` (scratchpad, chmod 600)
**Step 1: Generate the new secret and its hash**
```bash
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
umask 077
openssl rand -base64 24 | tr -d '\n' > "$SECRET_FILE"
chmod 600 "$SECRET_FILE"
NEW_SHA=$(cat "$SECRET_FILE" | tr -d '\n' | shasum -a 256 | awk '{print $1}')
echo "$NEW_SHA" # hash only — safe to display
```
Cross-check the hash recipe against `glauth.md` ("Generate `passsha256` from a plaintext password") in this repo and follow that recipe if it differs.
**Step 2: Diff host deployment config vs repo source of truth**
```bash
ssh 10.100.0.35 'cat /home/dohertj2/zb-glauth/config.toml' > /tmp/host-glauth-config.toml 2>/dev/null || true
diff ~/Desktop/scadaproj/infra/glauth/config.toml /tmp/host-glauth-config.toml
```
Small drift (comments, ports) is fine — note it. If the `serviceaccount` stanza differs structurally, STOP and surface before editing.
**Step 3: Edit the repo source of truth**
In `~/Desktop/scadaproj/infra/glauth/config.toml`, replace the `passsha256` value of the `[[users]]` entry whose `name`/`cn` is `serviceaccount` with `$NEW_SHA`. Edit ONLY that line. Do not `docker compose up` anything yet.
**Step 4: Record findings**
Report: hash staged (show hash, never plaintext), drift summary from step 2, and confirm `$SECRET_FILE` exists with mode 600.
---
### Task 2: SEC-36 — Determine wonder-app-vd03 LDAP status (via windev)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1, Task 6, Task 9
**Step 1: Try to reach vd03 from windev**
```bash
ssh 10.100.0.48 'powershell -NoProfile -Command "Test-Connection wonder-app-vd03 -Count 1 -Quiet"'
```
**Step 2: If reachable, read its gateway config for `MxGateway:Ldap:Enabled`**
Try (in order, stop at first success): `ssh` hop from windev; reading `\\wonder-app-vd03\c$\...` appsettings/environment via PowerShell remoting (`Invoke-Command -ComputerName wonder-app-vd03`); or `nssm get MxAccessGw AppEnvironmentExtra` remotely. Look for `MxGateway__Ldap__Enabled` / appsettings `Ldap:Enabled`.
**Step 3: Decide and record**
- `Enabled=false` or host unreachable/no gateway service → vd03 is OUT of scope; record why (runbook says its dashboard is disabled — `false` is the expected answer).
- `Enabled=true` → vd03 is IN scope for Task 3 pre-staging; record the connection method that worked.
---
### Task 3: SEC-36 — Pre-stage the NEW value on LDAP-enabled deployed hosts
**Classification:** high-risk
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 1, 2)
**Step 1: Pre-stage windev (10.100.0.48)**
Load the secret locally, then set the env var remotely without leaking it into logged command text more than unavoidable (ssh arguments are not logged remotely by default; do NOT echo the value):
```bash
SECRET_FILE="/private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/sec36-new-secret"
val=$(cat "$SECRET_FILE")
ssh 10.100.0.48 'powershell -NoProfile -Command "$v = [Console]::In.ReadLine(); $cur = (& nssm get MxAccessGw AppEnvironmentExtra) -join \"`n\"; Write-Output (\"CURRENT: \" + ($cur -replace \"Password=.*\", \"Password=<redacted>\")); & nssm set MxAccessGw AppEnvironmentExtra (\"MxGateway__Ldap__ServiceAccountPassword=\" + $v)"' <<< "$val"
```
**CAUTION:** `nssm set AppEnvironmentExtra` REPLACES the whole extra-environment block. First inspect `nssm get MxAccessGw AppEnvironmentExtra` (redacting any `Password=` values); if other variables exist, preserve them in the new value (newline-separated). Adapt quoting as needed — verify with a redacted `nssm get` afterwards.
**Step 2: Restart the service**
```bash
ssh 10.100.0.48 'nssm restart MxAccessGw'
```
Expected: service restarts. Binds against GLAuth now fail (old directory, new client value) — expected and brief; proceed immediately to Task 4.
**Step 3: vd03 (only if Task 2 said IN scope)** — same pre-stage + restart via the method Task 2 found.
---
### Task 4: SEC-36 — Rotate GLAuth and verify end-to-end
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 3)
**Step 1: Back up current host config, sync the staged config, recreate**
```bash
ssh 10.100.0.35 'cp /home/dohertj2/zb-glauth/config.toml /home/dohertj2/zb-glauth/config.toml.bak-sec36'
scp ~/Desktop/scadaproj/infra/glauth/config.toml 10.100.0.35:/home/dohertj2/zb-glauth/config.toml
```
**If Task 1's diff showed host-vs-repo drift beyond the serviceaccount line:** do NOT wholesale-copy — instead edit only the serviceaccount `passsha256` line in the host copy (sed on the host), so unrelated host-local drift is preserved.
```bash
ssh 10.100.0.35 'cd /home/dohertj2/zb-glauth && docker compose up -d --force-recreate && sleep 3 && docker compose logs --tail 30'
```
Expected: clean startup, no TOML parse error. On parse error: restore `.bak-sec36`, recreate, STOP, surface.
**Step 2: Verify new credential binds (from the glauth host, ldapsearch or python)**
```bash
SECRET_FILE=".../sec36-new-secret" # full scratchpad path
val=$(cat "$SECRET_FILE")
ssh 10.100.0.35 'ldapsearch -x -H ldap://localhost:3893 -D "cn=serviceaccount,dc=zb,dc=local" -w "$(cat -)" -b "dc=zb,dc=local" "(cn=multi-role)" cn' <<< "$val"
```
Expected: search returns the `multi-role` entry. (If ldapsearch is missing on the host, run the equivalent from macOS against `10.100.0.35:3893`, or use `docker exec`.) Adjust the bind DN to match the actual `serviceaccount` DN in config.toml.
**Step 3: Verify the OLD value is dead — exactly ONE probe, from 10.100.0.35 itself**
One deliberately failing bind with the old password must return invalid credentials. **Only one attempt** (3-fail/10-min per-IP lockout; never probe from a shared-NAT box). The old value: recover it transiently from `config.toml.bak-sec36`'s hash? No — hash is not the plaintext. Instead: skip the plaintext probe if the old plaintext is not already known out-of-band; the hash replacement in config.toml is itself proof GLAuth no longer honors the old value (GLAuth compares against `passsha256` only). Record that reasoning instead of probing blind.
**Step 4: Verify dashboard login end-to-end on windev**
```bash
curl -sk -o /dev/null -w '%{http_code}' -c /tmp/mxgw-cookies.txt https://10.100.0.48:5001/login
```
Find the actual dashboard port from windev config first (`nssm get`/appsettings; likely https). Then POST the login form as `multi-role`/`password` (the GLAuth TEST USER password, not the service account) and expect a redirect + `__Host-MxGatewayDashboard` (or `MxGatewayDashboard`) cookie:
```bash
curl -sk -o /dev/null -w '%{http_code}\n' -b /tmp/mxgw-cookies.txt -c /tmp/mxgw-cookies.txt -d 'username=multi-role&password=password' <dashboard-base>/login
grep -i mxgatewaydashboard /tmp/mxgw-cookies.txt
```
Inspect the login page HTML first for real form field names / antiforgery token; adapt. A successful `multi-role` login proves the service-account search bind works with the new credential end-to-end. If HTTP verification proves impractical (antiforgery), fall back to grepping the gateway log on windev for a successful LDAP bind/login line after attempting — or run the live-LDAP integration test from macOS:
```bash
export MXGATEWAY_RUN_LIVE_LDAP_TESTS=1
export MxGateway__Ldap__ServiceAccountPassword="$(cat "$SECRET_FILE")"
dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~DashboardLdapLiveTests
```
Expected: green. (This binds from macOS to 10.100.0.35:3893 directly — it verifies the credential, and the curl/log check verifies windev.)
**Step 5: Rollback (only on failure)** — restore `.bak-sec36` on the host, `docker compose up -d --force-recreate`, re-point windev's env var back (old value from where it was before — if unknown, STOP and surface), `nssm restart MxAccessGw`.
---
### Task 5: SEC-36 — Finalize: commit source of truth, dev secrets, runbook fix, tracker, cleanup
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 4)
**Step 1: Commit and push the scadaproj glauth change (glauth paths ONLY)**
```bash
cd ~/Desktop/scadaproj
git add infra/glauth/config.toml
git commit -m "sec(glauth): rotate serviceaccount passsha256 (mxaccessgw SEC-36)"
git push
```
(`scadaproj` is a shared monorepo — stage only this path. If the worktree has unrelated staged changes, use `git commit -- infra/glauth/config.toml` style isolation.)
**Step 2: Set dev user-secrets on this macOS box**
```bash
cd ~/Desktop/MxAccessGateway
cat "$SECRET_FILE" | tr -d '\n' | dotnet user-secrets set "MxGateway:Ldap:ServiceAccountPassword" --project src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
```
(Check `dotnet user-secrets set -h` for stdin support; if unsupported, pass via `"$(cat "$SECRET_FILE")"` — acceptable, it's a local process arg.)
**Step 3: Correct the runbook + flip tracker rows (mxaccessgw repo)**
- `docs/runbooks/SEC-36-ldap-credential-rotation.md`: fix the host deployment path (`/home/dohertj2/zb-glauth`, container `zb-shared-glauth`; repo source of truth remains `scadaproj/infra/glauth/`), and note vd03's actual status per Task 2.
- Grep `archreview/2026-07-12/remediation/` for SEC-36 pending-operator rows; flip to Done citing the runbook + today's date.
```bash
cd ~/Desktop/MxAccessGateway
grep -rn "SEC-36" archreview/2026-07-12/remediation/ docs/ | grep -iv binary
# edit the rows, then:
git add -A docs archreview && git commit -m "docs(sec-36): record live rotation done; correct runbook host paths"
```
**Step 4: Shred the secret file**
```bash
rm -P "$SECRET_FILE" 2>/dev/null || rm "$SECRET_FILE"
```
**Step 5: Done-criteria check** — walk the runbook's Done criteria list; report each as met/not-met.
---
### Task 6: TST-30 — Recon existing runner config on 10.100.0.35
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 9
**Step 1: Inspect the existing runner**
```bash
ssh 10.100.0.35 'cat /opt/gitea/docker-compose.yml 2>/dev/null || sudo cat /opt/gitea/docker-compose.yml; ls /opt/gitea'
ssh 10.100.0.35 'docker inspect gitea-runner --format "{{json .Mounts}}"; docker exec gitea-runner cat /config.yaml 2>/dev/null || true'
```
Find: image/version, config file location (look for `container.network: traefik` and `capacity`/`maxParallel`), data volume, registration state file, docker socket mount, labels.
**Step 2: Check host capacity**
```bash
ssh 10.100.0.35 'nproc; free -h; df -h / | tail -1'
```
**Step 3: Decide (a)-variant** — second container vs raising `capacity` on the existing runner. Runbook prefers a second instance; if the existing runner's config shows a simple `capacity: 1` and resources are tight, raising capacity is the smaller change — but a second registered instance is the runbook default and survives one-runner wedge. Record the chosen variant, the exact compose/config snippets to reuse, and where the registration token goes.
---
### Task 7: TST-30 — Register and start the second runner
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 6)
**Step 1: Mint an instance-level registration token**
```bash
source ~/.zshenv
curl -s -X POST -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners/registration-token'
```
(Returns `{"token": "..."}` — a registration token, not a secret credential of lasting value; still avoid committing it.)
**Step 2: Create the second runner instance per Task 6's plan**
E.g. add a `gitea-runner-2` service to the compose (distinct name + data volume, same image, same `container.network: traefik`, same socket mount), inject the token via the runner's registration env (`GITEA_RUNNER_REGISTRATION_TOKEN`) or `act_runner register --no-interactive`, then `docker compose up -d gitea-runner-2` from `/opt/gitea`. Back up the compose file first (`cp docker-compose.yml docker-compose.yml.bak-tst30`). Do NOT touch the existing `gitea-runner` service definition.
**Step 3: Confirm both runners online**
```bash
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/admin/actions/runners' | python3 -m json.tool
```
Expected: ≥2 runners, both online. Also check `docker logs` of the new container for a clean registration + poll loop.
---
### Task 8: TST-30 — Verify concurrency, gitea:3000 resolution, tracker
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (blocked by Task 7)
**Step 1: Trigger two concurrent runs**
Push two scratch branches to `mxaccessgw` back-to-back (empty commits off `main`, branch names `scratch/tst30-a`, `scratch/tst30-b`):
```bash
cd ~/Desktop/MxAccessGateway
git push origin main:refs/heads/scratch/tst30-a
git commit --allow-empty -m "tst30 concurrency probe" && git push origin HEAD:refs/heads/scratch/tst30-b && git reset --hard HEAD~1
```
(Adapt: any two pushes that fan out jobs. Clean up branches after: `git push origin :scratch/tst30-a :scratch/tst30-b`.)
**Step 2: Confirm parallel execution**
Poll the runs API/UI: the second run's jobs must START before the first run finishes.
```bash
curl -s -u "$GITEA_USERNAME:$GITEA_TOKEN" 'https://gitea.dohertylan.com/api/v1/repos/dohertj2/mxaccessgw/actions/tasks' | python3 -m json.tool | head -60
```
**Step 3: Confirm `gitea:3000` resolves on the new runner** — verify a job scheduled on runner-2 succeeds at checkout (checkout hits `gitea:3000` over the traefik network); identify which runner took each job from the runs UI/API or runner logs.
**Step 4: Flip TST-30 tracker rows** in `archreview/2026-07-12/remediation/` (grep `TST-30`) to Done with today's date; confirm `docs/GatewayTesting.md` prose is still accurate (it should be — it already describes the bypass as valid regardless of runner count). Commit.
---
### Task 9: Publish — Preflight audit (versions, registry collisions, toolchains)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2, Task 6
**Step 1: Audit source versions**
```bash
cd ~/Desktop/MxAccessGateway
grep -n 'version' clients/rust/Cargo.toml | head -5
grep -n 'version' clients/python/pyproject.toml clients/python/src/zb_mom_ww_mxgateway/version.py
grep -n 'ClientVersion' clients/go/mxgateway/version.go
grep -n '<Version>' clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj
grep -n 'version' clients/java/build.gradle | head -5
grep -rn 'CLIENT_VERSION' clients/java --include=*.java | grep -i mxgatewayclientversion
```
Expected: Rust/Python/Go/.NET/Contracts = 0.2.0; Java build.gradle AND `MxGatewayClientVersion.CLIENT_VERSION` = 0.2.1. Any mismatch → STOP, surface (do not bump versions yourself; that's a scope change).
**Step 2: Query live registry for collisions**
```bash
source ~/.zshenv
for u in 'nuget/ZB.MOM.WW.MxGateway.Client/0.2.0' 'nuget/ZB.MOM.WW.MxGateway.Contracts/0.2.0' 'pypi/zb-mom-ww-mxaccess-gateway-client/0.2.0' 'cargo/zb-mom-ww-mxgateway-client/0.2.0' 'maven/com.zb.mom.ww.mxgateway-zb-mom-ww-mxgateway-client/0.2.1'; do
echo "$u => $(curl -s -o /dev/null -w '%{http_code}' -u "$GITEA_USERNAME:$GITEA_TOKEN" "https://gitea.dohertylan.com/api/v1/packages/dohertj2/$u")"
done
```
Expected: 404 for every target (unclaimed). Check the exact maven path convention against `pack-clients.ps1`'s own guard code and use its convention. 200 anywhere → STOP, surface.
**Step 3: Toolchain + workspace check**
```bash
git -C ~/Desktop/MxAccessGateway status --porcelain # must be clean (publish from a clean tree at origin/main)
for t in dotnet cargo go python3 gradle pwsh; do which $t; done
```
Also confirm `clients/go` module tag `clients/go/v0.2.0` does NOT already exist: `git ls-remote --tags origin 'clients/go/v*'`.
---
### Task 10: Publish — Run the guarded pack-and-publish
**Classification:** high-risk
**Estimated implement time:** ~5 min dispatch (script runtime longer)
**Parallelizable with:** none (blocked by Task 9)
**Step 1: Run pack-clients with publish**
```bash
cd ~/Desktop/MxAccessGateway
source ~/.zshenv
pwsh -NoProfile -File scripts/pack-clients.ps1 -Publish 2>&1 | tee /private/tmp/claude-501/-Users-dohertj2-Desktop-MxAccessGateway/67849767-a07c-4afa-94e2-3ce4a39d8d23/scratchpad/pack-clients-publish.log
```
Expected: per-language build+test+pack, collision guard prints "safe to publish" per artifact, uploads succeed. Timeout generously (Bash timeout 600000). If any language fails MID-loop, record exactly which artifacts pushed and which didn't — partial publish is the known failure mode; do not re-run blindly (re-run is safe only because the guard skips? NO — the guard ABORTS on existing versions. A re-run after partial publish will abort on the already-pushed artifact. If that happens, surface with the log; per-language `-Languages` selective re-run is the fix).
If macOS cannot build a language (e.g. gradle/java env), use `-Languages` to publish what builds and surface the remainder — do not fake success.
**Step 2: Verify each artifact now exists (200)** — re-run Task 9 step 2's loop; expected 200 everywhere published.
---
### Task 11: Publish — Go module tag
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10 (blocked by Task 9)
**Step 1: Tag via the guarded script**
```bash
cd ~/Desktop/MxAccessGateway
pwsh -NoProfile -File scripts/tag-go-module.ps1 -Version 0.2.0
```
Read the script's param block first (`-Version` name may differ; it validates semver and that `version.go` matches, then creates+pushes `clients/go/v0.2.0`). Expected: tag created and pushed to origin.
**Step 2: Verify**
```bash
git ls-remote --tags origin 'clients/go/v0.2.0*'
```
Expected: exactly one tag. Optionally `GOPROXY=direct go list -m gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.2.0` from a temp dir.
---
### Task 12: Publish — Docs/tracker closeout
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (blocked by Tasks 10, 11)
**Step 1:** Update `docs/ClientPackaging.md`'s versioning narrative if it claims 0.2.0/0.2.1 are unpublished (it currently records the maven 0.2.1 exception; add a dated line that 0.2.0 (Java 0.2.1) published on 2026-08-07). Grep `archreview/2026-07-12/remediation/` for publish/CLI-39 pending-operator rows and flip to Done.
**Step 2:** Commit:
```bash
cd ~/Desktop/MxAccessGateway
git add docs archreview && git commit -m "docs(clients): record 0.2.0/0.2.1 publish + close operator actions"
```
**Step 3:** Report the full publish matrix (artifact → version → registry HTTP status).
---
## Dependency graph
```
{T1, T2} ──▶ T3 ──▶ T4 ──▶ T5 (SEC-36, strictly serial after recon)
T6 ──▶ T7 ──▶ T8 (TST-30)
T9 ──▶ {T10, T11} ──▶ T12 (Publish)
```
The three streams are mutually independent and run concurrently. All subagents run with model=opus per operator instruction.
## Out of scope (explicitly)
- Option (b)/(c) runner topologies and the `concurrency:` ci.yml experiment (TST-30 runbook marks them escalation/optional).
- The five next-cycle candidate findings in `archreview/2026-07-12/remediation/90-candidate-findings-next-cycle.md`.
- Any client version bumps (versions are already landed; a mismatch is a STOP-and-surface).
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-08-07-live-actions-sec36-tst30-publish.md",
"tasks": [
{"id": 1, "subject": "Task 1: SEC-36 — Generate secret, stage GLAuth config change", "status": "completed"},
{"id": 2, "subject": "Task 2: SEC-36 — Determine wonder-app-vd03 LDAP status via windev", "status": "completed"},
{"id": 3, "subject": "Task 3: SEC-36 — Pre-stage NEW value on LDAP-enabled hosts (nssm + restart)", "status": "completed", "blockedBy": [1, 2]},
{"id": 4, "subject": "Task 4: SEC-36 — Rotate GLAuth and verify end-to-end", "status": "completed", "blockedBy": [3]},
{"id": 5, "subject": "Task 5: SEC-36 — Finalize: commit, dev secrets, runbook fix, tracker, cleanup", "status": "completed", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: TST-30 — Recon existing runner config on 10.100.0.35", "status": "completed"},
{"id": 7, "subject": "Task 7: TST-30 — Register and start the second runner", "status": "completed", "blockedBy": [6]},
{"id": 8, "subject": "Task 8: TST-30 — Verify concurrency + gitea:3000 + tracker", "status": "completed", "blockedBy": [7]},
{"id": 9, "subject": "Task 9: Publish — Preflight audit (versions, collisions, toolchains)", "status": "completed"},
{"id": 10, "subject": "Task 10: Publish — Run pack-clients.ps1 -Publish", "status": "completed", "blockedBy": [9]},
{"id": 11, "subject": "Task 11: Publish — Go module tag clients/go/v0.2.0", "status": "completed", "blockedBy": [9]},
{"id": 12, "subject": "Task 12: Publish — Docs/tracker closeout", "status": "completed", "blockedBy": [10, 11]}
],
"lastUpdated": "2026-08-07 (all tasks executed; 4 closeout commits local on main, not pushed)"
}
+8 -1
View File
@@ -299,9 +299,16 @@ Default transport: one bidirectional named pipe per worker.
Pipe name: Pipe name:
```text ```text
mxaccess-gateway-{gatewayProcessId}-{sessionId} mxgw-{gatewayProcessId}-{sessionUid}
``` ```
`sessionUid` is the session id without its `session-` prefix (the raw guid hex).
The name is deliberately short: on Unix-like hosts (the macOS/Linux test
matrix), .NET named pipes are Unix domain sockets at
`$TMPDIR/CoreFxPipe_{name}`, and macOS caps the socket path at 104 bytes while
its default per-user `TMPDIR` already spends ~49 of them. The gateway PID keeps
the name collision-free across gateway restarts.
Message framing: Message framing:
```text ```text
@@ -417,7 +417,8 @@ public sealed class SessionManager : ISessionManager
string? clientIdentity, string? clientIdentity,
string? ownerKeyId) string? ownerKeyId)
{ {
string sessionId = CreateSessionId(); string sessionUid = Guid.NewGuid().ToString("N");
string sessionId = $"session-{sessionUid}";
string backendName = string.IsNullOrWhiteSpace(request.RequestedBackend) string backendName = string.IsNullOrWhiteSpace(request.RequestedBackend)
? GatewayContractInfo.DefaultBackendName ? GatewayContractInfo.DefaultBackendName
: request.RequestedBackend!; : request.RequestedBackend!;
@@ -425,7 +426,11 @@ public sealed class SessionManager : ISessionManager
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds); TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds); TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds); TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds);
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}"; // The short prefix and bare guid keep the pipe's Unix-domain-socket path
// (TMPDIR + "CoreFxPipe_" + name) inside the 104-byte sun_path limit on
// macOS, whose default per-user TMPDIR is ~49 chars; the gateway PID keeps
// the name collision-free across gateway restarts (NEXT-01).
string pipeName = $"mxgw-{Environment.ProcessId}-{sessionUid}";
string nonce = CreateNonce(); string nonce = CreateNonce();
DateTimeOffset openedAt = _timeProvider.GetUtcNow(); DateTimeOffset openedAt = _timeProvider.GetUtcNow();
string clientCorrelationId = CreateClientCorrelationId(request.ClientSessionName, sessionId); string clientCorrelationId = CreateClientCorrelationId(request.ClientSessionName, sessionId);
@@ -484,11 +489,6 @@ public sealed class SessionManager : ISessionManager
: timeout; : timeout;
} }
private static string CreateSessionId()
{
return $"session-{Guid.NewGuid():N}";
}
private static string CreateNonce() private static string CreateNonce()
{ {
Span<byte> bytes = stackalloc byte[32]; Span<byte> bytes = stackalloc byte[32];
@@ -37,6 +37,36 @@ public sealed class SessionManagerTests
Assert.Equal(1, metrics.GetSnapshot().SessionsOpened); Assert.Equal(1, metrics.GetSnapshot().SessionsOpened);
} }
/// <summary>
/// Verifies the pipe name stays short enough that its Unix-domain-socket path
/// (TMPDIR + "CoreFxPipe_" + name) fits the 104-byte macOS sun_path limit under the
/// default per-user TMPDIR (~49 chars), and keeps the pid + session-guid uniqueness
/// contract (NEXT-01).
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task OpenSessionAsync_PipeNameIsShortAndUniquePerPidAndSession()
{
FakeWorkerClient workerClient = new();
FakeSessionWorkerClientFactory factory = new(workerClient)
{
ApplyLifecycleTransitions = true,
};
SessionManager manager = CreateManager(factory);
GatewaySession session = await manager.OpenSessionAsync(CreateOpenRequest(), "client-1", ownerKeyId: null, CancellationToken.None);
Assert.Matches($"^mxgw-{Environment.ProcessId}-[0-9a-f]{{32}}$", session.PipeName);
Assert.EndsWith(session.SessionId["session-".Length..], session.PipeName, StringComparison.Ordinal);
// 104-byte sun_path NUL ~49-char default macOS TMPDIR "CoreFxPipe_".
const int MaxPipeNameLength = 104 - 1 - 49 - 11;
int worstCasePidDigits = 5 - Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture).Length;
Assert.True(
session.PipeName.Length + Math.Max(0, worstCasePidDigits) <= MaxPipeNameLength,
$"Pipe name '{session.PipeName}' would overflow the macOS socket-path budget at a 5-digit pid.");
}
/// <summary>Verifies that a session opened by an authenticated caller records that caller's API key id in OwnerKeyId.</summary> /// <summary>Verifies that a session opened by an authenticated caller records that caller's API key id in OwnerKeyId.</summary>
/// <returns>A task that represents the asynchronous operation.</returns> /// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
@@ -138,7 +138,7 @@ public sealed class SessionWorkerClientFactoryFakeWorkerTests : IAsyncDisposable
return new GatewaySession( return new GatewaySession(
FakeWorkerHarness.DefaultSessionId, FakeWorkerHarness.DefaultSessionId,
GatewayContractInfo.DefaultBackendName, GatewayContractInfo.DefaultBackendName,
$"mxaccessgw-session-fake-worker-{Guid.NewGuid():N}", $"mxgw-sf-{Guid.NewGuid():N}",
FakeWorkerHarness.DefaultNonce, FakeWorkerHarness.DefaultNonce,
"test-client", "test-client",
"fake-worker-session-test", "fake-worker-session-test",
@@ -60,7 +60,7 @@ public sealed class FakeWorkerHarness : IAsyncDisposable
int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes, int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
string pipeName = $"mxaccessgw-fake-worker-{Guid.NewGuid():N}"; string pipeName = $"mxgw-fw-{Guid.NewGuid():N}";
NamedPipeServerStream gatewayStream = new( NamedPipeServerStream gatewayStream = new(
pipeName, pipeName,
PipeDirection.InOut, PipeDirection.InOut,
@@ -1069,7 +1069,7 @@ public sealed class WorkerClientTests
/// <returns>The connected <see cref="PipePair"/>.</returns> /// <returns>The connected <see cref="PipePair"/>.</returns>
public static async Task<PipePair> CreateAsync() public static async Task<PipePair> CreateAsync()
{ {
string pipeName = $"mxaccessgw-workerclient-tests-{Guid.NewGuid():N}"; string pipeName = $"mxgw-wc-{Guid.NewGuid():N}";
NamedPipeServerStream gatewayStream = new( NamedPipeServerStream gatewayStream = new(
pipeName, pipeName,
PipeDirection.InOut, PipeDirection.InOut,