Release 0.2.1-jrich-3 cannot complete asset setup or start training on a correctly configured host. Four separate blocking defects, plus one silent-corruption defect that would produce a bad model once the blockers are cleared.
None of this is host misconfiguration and none of it is WSL-specific - a plain Linux host with the default umask 022 fails identically. Every finding below was reproduced, bisected, and then re-verified against a patched copy that trains end to end.
Environment used for diagnosis: Ubuntu 24.04 on WSL2, Python 3.12.9, Docker 29.7.2, RTX 3090 (driver 591.86), NVIDIA Container Toolkit working, 855 GB free. All 28 files verified against the shipped SHA256SUMS.
1. Container runs as uid 0 with --cap-drop ALL against host-owned bind mounts - BLOCKER
scripts/wakeword_trainer.py passes --cap-drop ALL. The image declares no USER, so the container is uid 0, but --cap-drop ALL removes CAP_DAC_OVERRIDE. Root cannot write a bind-mounted directory it does not own, and the launcher creates the cache as the invoking user at mode 0755:
2. --read-only rootfs + HOME=/root with no HF_HOME - BLOCKER
The container gets --tmpfs /tmp but HOME stays /root on the read-only rootfs, and no HuggingFace cache variables are set. Every HF-backed dataset fails:
WARNING Failed to download ACAV100M features: I/O error: Read-only file system (os error 30)
WARNING Failed to download validation features: ...
WARNING Failed to download RIRs: ...
WARNING Failed to download MUSAN noise: ...
INFO Setup complete!
After a "successful" setup the cache held 167 MB instead of ~17 GB - only the Piper checkpoint, which uses plain HTTP rather than huggingface_hub.
3. Root-created 0600 files break the host-side inventory - BLOCKER
Even when writes succeed, the container creates local_dir/.cache/huggingface/trees/*.json as root:root mode 0600. The host-side inventory then cannot read them:
COPY preserves build-context modes, and nothing in the Dockerfile loosens them. This was invisible while the container ran as root. /opt/conda is 755, so setup alone survives an unprivileged uid; only the training stage breaks.
This means the permission fix cannot be launcher-only - the image must be rebuilt.
5. Archive-layout import bug - BLOCKER, independent of permissions
The entry point scripts/jrich-wakeword-trainer puts scripts/ on sys.path, not the archive root, so the scripts. prefix is unresolvable in an extracted archive. Training dies before any container launches:
The launcher already solves this exact problem in scripts/wakeword_trainer.py:
# Import shape differs only between the extracted# archive (sibling module) and repository tests (scripts package).try:fromwakeword_trainingimportRunKind,WakewordTrainingRequestexceptModuleNotFoundError:fromscripts.wakeword_trainingimportRunKind,WakewordTrainingRequest
There are exactly two scripts.-qualified imports in the archive; one is guarded, one is not. Training therefore only ever worked from a repo checkout - the release was never exercised from its own artifact.
6. Failed downloads reported as success - SILENT CORRUPTION, not a blocker
This one survives all the fixes above and is arguably the most dangerous.
livekit/wakewordcli.py downgrades every download failure to a WARNING, then prints Setup complete! and exits 0. The runner seals whatever landed into the immutable source lock and treats it as a valid input set. Observed: a source lock over 1008 files / 17.8 GB in which the MUSAN background corpus was 227 of 774 files, with nothing in the provenance record indicating a problem.
Compounding it, _download_musan_noise short-circuits on any non-zero count:
existing=list(bg_dir.glob("**/*.wav"))ifexisting:logger.info(f"Background noise already present: {len(existing)} files in {bg_dir}")return
So one transient HTTP 429 permanently pins the corpus at a partial count, and every subsequent setup reports success. Recovery requires deleting the entire backgrounds/ directory and re-pulling all 1.1 GB. Reproduced twice (227/774, then 768/774); a wipe-and-retry loop was needed to reach 774/774.
There is also no HF_TOKEN passthrough and no retry on 429. Unauthenticated pulls rate-limit reliably at this data volume:
WARNING Warning: You are sending unauthenticated requests to the HF Hub.
Please set a HF_TOKEN to enable higher rate limits and faster downloads.
Suggested: treat an incomplete asset set as fatal, verify counts against the expected manifest before sealing a source lock, and add token passthrough with retry/backoff.
7. Windows entry point is blocked by execution policy - end-user papercut
Under the default RemoteSigned policy for CurrentUser, a downloaded start-windows.ps1 carries mark-of-the-web and "Run with PowerShell" fails before anything starts:
File ...\start-windows.ps1 cannot be loaded. The file is not digitally signed.
You cannot run this script on the current system.
Users can Unblock-File, but the README says to right-click -> Run with PowerShell, which does not work on a stock machine. Consider signing the script, shipping a .cmd shim, or documenting the unblock step.
Proposed fix
Launcher (this repo)
Applies cleanly to a pristine 0.2.1-jrich-3 archive with patch -p1:
--- a/scripts/wakeword_trainer.py
+++ b/scripts/wakeword_trainer.py
@@ -83,6 +83,8 @@
) -> tuple[str, ...]:
"""Build the explicit networked cache-population command; training stays offline."""
+ from wakeword_training import unprivileged_container_args
+
cache_dir.mkdir(parents=True, exist_ok=True)
return (
"docker",
@@ -96,6 +98,7 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ *unprivileged_container_args(),
"--mount",
f"type=bind,src={cache_dir.resolve()},dst=/data",
"--mount",
@@ -352,6 +355,8 @@
def _preflight(plan: dict[str, Any]) -> None:
+ from wakeword_training import unprivileged_container_args
+
runtime = plan["runtime"]
for executable in ("docker", "nvidia-smi"):
if shutil.which(executable) is None:
@@ -384,6 +389,9 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ # Preflight under the same uid the real stages use, so this check
+ # cannot pass in a configuration training will not run in.
+ *unprivileged_container_args(),
runtime["image_ref"],
"python",
"-c",
--- a/scripts/wakeword_training.py
+++ b/scripts/wakeword_training.py
@@ -73,6 +73,32 @@
)
+def unprivileged_container_args() -> tuple[str, ...]:
+ """Run the container as the invoking user with caches off the read-only rootfs.
+
+ The image declares no ``USER``, so without this the container is uid 0 while
+ ``--cap-drop ALL`` removes ``CAP_DAC_OVERRIDE``. Root then cannot write a
+ bind-mounted host directory it does not own, and any file it does create is
+ unreadable to the host user that has to inventory it afterwards. Matching the
+ host uid/gid keeps both directions working under the same dropped capabilities.
+
+ ``HOME`` defaults to ``/root`` on the read-only rootfs, so HuggingFace and
+ friends fail with ``Read-only file system``. Point the HOME-derived cache
+ paths at the writable tmpfs the callers already mount at ``/tmp``.
+ """
+
+ return (
+ "--user",
+ f"{os.getuid()}:{os.getgid()}",
+ "--env",
+ "HOME=/tmp/home",
+ "--env",
+ "HF_HOME=/tmp/home/hf",
+ "--env",
+ "XDG_CACHE_HOME=/tmp/home/cache",
+ )
+
+
class RunKind(StrEnum):
"""Whether an attempt may ever be considered for commercial publication."""
@@ -839,7 +865,12 @@
# The image wrapper controls the commercial stage sequence and rejects
# an upstream setup/download path. The executor deliberately exposes no
# caller override that could bypass the sealed image contract.
- from scripts.wakeword_training_image import build_stage_command
+ # Import shape differs only between the extracted archive (sibling
+ # module) and repository tests (scripts package), as in the launcher.
+ try:
+ from wakeword_training_image import build_stage_command
+ except ModuleNotFoundError:
+ from scripts.wakeword_training_image import build_stage_command
stage_command = build_stage_command(context.run_kind.value)
container_name = context.container_name
@@ -859,6 +890,7 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ *unprivileged_container_args(),
"--env",
"PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True",
"--mount",
Image (requires rebuild + re-pin)
COPY --chmod=0644 deploy/wakeword-training/run-commercial-pipeline.py ./run-commercial-pipeline.py# ...same for every COPY into /opt/jr-wakeword
Optionally move HOME / HF_HOME / XDG_CACHE_HOME into the Dockerfile as ENV and drop them from the launcher, since a rebuild is required regardless.
Rejected alternative
Keeping uid 0 and adding --cap-add DAC_OVERRIDE avoids the image rebuild and does fix the write failures - but not defect 3. Root still creates 0600 root:root files that the uid-1000 host inventory cannot hash, which is the original exit-2. --user is the only complete answer.
Verification
With the launcher patch plus an image whose /opt/jr-wakeword is world-readable, a full local experiment at default umask 022 - no chmod, no wrapper, no workaround:
Zero permission or read-only errors, and every artifact owned by and readable to the invoking user. Setup independently reaches exit 0 with a complete 17 GB cache (ACAV100M, 270 RIRs, 774/774 MUSAN, Piper checkpoint).
A full-scale run (50,000 steps, 10,000 samples, medium conv_attention, Piper batch 10) is in progress on the patched copy and has cleared generation, all three augmentation rounds, and feature extraction with no errors.
Note: verification used a derived image (FROM the pinned digest + chmod -R a+rX /opt/jr-wakeword) rather than a full wheelhouse rebuild. Behaviourally equivalent for this purpose, but the real change belongs in the COPY directives so the released digest carries it.
Suggested release gate
Extract the built .tar.gz / .zip into a clean directory as a non-root user and run it from there. Every blocker above reproduces on the first attempt, and defect 5 in particular cannot reproduce from a repo checkout.
## Summary
Release `0.2.1-jrich-3` cannot complete asset setup **or** start training on a correctly configured host. Four separate blocking defects, plus one silent-corruption defect that would produce a bad model once the blockers are cleared.
None of this is host misconfiguration and none of it is WSL-specific - a plain Linux host with the default `umask 022` fails identically. Every finding below was reproduced, bisected, and then re-verified against a patched copy that trains end to end.
**Environment used for diagnosis:** Ubuntu 24.04 on WSL2, Python 3.12.9, Docker 29.7.2, RTX 3090 (driver 591.86), NVIDIA Container Toolkit working, 855 GB free. All 28 files verified against the shipped `SHA256SUMS`.
---
## 1. Container runs as uid 0 with `--cap-drop ALL` against host-owned bind mounts - BLOCKER
`scripts/wakeword_trainer.py` passes `--cap-drop ALL`. The image declares no `USER`, so the container is uid 0, but `--cap-drop ALL` removes `CAP_DAC_OVERRIDE`. Root cannot write a bind-mounted directory it does not own, and the launcher creates the cache as the invoking user at mode `0755`:
```
PermissionError: [Errno 13] Permission denied: '/data/piper'
error: LiveKit asset setup failed
```
Bisected the hardening flags individually - `--read-only` and `no-new-privileges` are both harmless here; `--cap-drop ALL` is the sole cause.
The same pattern applies to the training stage's `/run` mount in `scripts/wakeword_training.py`. Simulated it directly:
```
mkdir: cannot create directory '/run/output': Permission denied
touch: cannot touch '/run/pipeline-time-v.txt': Permission denied
```
## 2. `--read-only` rootfs + `HOME=/root` with no `HF_HOME` - BLOCKER
The container gets `--tmpfs /tmp` but `HOME` stays `/root` on the read-only rootfs, and no HuggingFace cache variables are set. Every HF-backed dataset fails:
```
WARNING Failed to download ACAV100M features: I/O error: Read-only file system (os error 30)
WARNING Failed to download validation features: ...
WARNING Failed to download RIRs: ...
WARNING Failed to download MUSAN noise: ...
INFO Setup complete!
```
After a "successful" setup the cache held **167 MB instead of ~17 GB** - only the Piper checkpoint, which uses plain HTTP rather than `huggingface_hub`.
## 3. Root-created `0600` files break the host-side inventory - BLOCKER
Even when writes succeed, the container creates `local_dir/.cache/huggingface/trees/*.json` as `root:root` mode `0600`. The host-side inventory then cannot read them:
```
error: [Errno 13] Permission denied:
.../livekit-0.2.1/backgrounds/.cache/huggingface/trees/3edcf...json
```
Real exit code 2. The resulting cache also could not be removed without going through a root container.
## 4. `/opt/jr-wakeword/*` is `0600 root:root` in the image - BLOCKER for the fix
Fixing 1-3 by running the container as the invoking user immediately hits:
```
python: can't open file '/opt/jr-wakeword/run-local-experiment.py': [Errno 13] Permission denied
```
Every file the Dockerfile `COPY`s is mode `0600`:
```
600 /opt/jr-wakeword/run-local-experiment.py
600 /opt/jr-wakeword/run-commercial-pipeline.py
600 /opt/jr-wakeword/wakeword_training_image.py
600 /opt/jr-wakeword/finalize-run.py
600 /opt/jr-wakeword/stage_evidence.py
...
```
`COPY` preserves build-context modes, and nothing in the Dockerfile loosens them. This was invisible while the container ran as root. `/opt/conda` is `755`, so setup alone survives an unprivileged uid; only the training stage breaks.
**This means the permission fix cannot be launcher-only - the image must be rebuilt.**
## 5. Archive-layout import bug - BLOCKER, independent of permissions
`scripts/wakeword_training.py` does a bare:
```python
from scripts.wakeword_training_image import build_stage_command
```
The entry point `scripts/jrich-wakeword-trainer` puts `scripts/` on `sys.path`, not the archive root, so the `scripts.` prefix is unresolvable in an extracted archive. Training dies before any container launches:
```
terminal_reason : executor_error:ModuleNotFoundError
stage : pipeline
elapsed : 0.048s
```
The launcher already solves this exact problem in `scripts/wakeword_trainer.py`:
```python
# Import shape differs only between the extracted
# archive (sibling module) and repository tests (scripts package).
try:
from wakeword_training import RunKind, WakewordTrainingRequest
except ModuleNotFoundError:
from scripts.wakeword_training import RunKind, WakewordTrainingRequest
```
There are exactly two `scripts.`-qualified imports in the archive; one is guarded, one is not. Training therefore only ever worked from a repo checkout - **the release was never exercised from its own artifact.**
---
## 6. Failed downloads reported as success - SILENT CORRUPTION, not a blocker
This one survives all the fixes above and is arguably the most dangerous.
`livekit/wakeword` `cli.py` downgrades *every* download failure to a `WARNING`, then prints `Setup complete!` and exits 0. The runner seals whatever landed into the immutable source lock and treats it as a valid input set. Observed: a source lock over 1008 files / 17.8 GB in which the MUSAN background corpus was **227 of 774 files**, with nothing in the provenance record indicating a problem.
Compounding it, `_download_musan_noise` short-circuits on *any* non-zero count:
```python
existing = list(bg_dir.glob("**/*.wav"))
if existing:
logger.info(f"Background noise already present: {len(existing)} files in {bg_dir}")
return
```
So one transient HTTP 429 permanently pins the corpus at a partial count, and every subsequent setup reports success. Recovery requires deleting the entire `backgrounds/` directory and re-pulling all 1.1 GB. Reproduced twice (227/774, then 768/774); a wipe-and-retry loop was needed to reach 774/774.
There is also no `HF_TOKEN` passthrough and no retry on 429. Unauthenticated pulls rate-limit reliably at this data volume:
```
WARNING Warning: You are sending unauthenticated requests to the HF Hub.
Please set a HF_TOKEN to enable higher rate limits and faster downloads.
```
**Suggested:** treat an incomplete asset set as fatal, verify counts against the expected manifest before sealing a source lock, and add token passthrough with retry/backoff.
---
## 7. Windows entry point is blocked by execution policy - end-user papercut
Under the default `RemoteSigned` policy for CurrentUser, a downloaded `start-windows.ps1` carries mark-of-the-web and "Run with PowerShell" fails before anything starts:
```
File ...\start-windows.ps1 cannot be loaded. The file is not digitally signed.
You cannot run this script on the current system.
```
Users can `Unblock-File`, but the README says to right-click -> Run with PowerShell, which does not work on a stock machine. Consider signing the script, shipping a `.cmd` shim, or documenting the unblock step.
---
## Proposed fix
### Launcher (this repo)
Applies cleanly to a pristine `0.2.1-jrich-3` archive with `patch -p1`:
```diff
--- a/scripts/wakeword_trainer.py
+++ b/scripts/wakeword_trainer.py
@@ -83,6 +83,8 @@
) -> tuple[str, ...]:
"""Build the explicit networked cache-population command; training stays offline."""
+ from wakeword_training import unprivileged_container_args
+
cache_dir.mkdir(parents=True, exist_ok=True)
return (
"docker",
@@ -96,6 +98,7 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ *unprivileged_container_args(),
"--mount",
f"type=bind,src={cache_dir.resolve()},dst=/data",
"--mount",
@@ -352,6 +355,8 @@
def _preflight(plan: dict[str, Any]) -> None:
+ from wakeword_training import unprivileged_container_args
+
runtime = plan["runtime"]
for executable in ("docker", "nvidia-smi"):
if shutil.which(executable) is None:
@@ -384,6 +389,9 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ # Preflight under the same uid the real stages use, so this check
+ # cannot pass in a configuration training will not run in.
+ *unprivileged_container_args(),
runtime["image_ref"],
"python",
"-c",
--- a/scripts/wakeword_training.py
+++ b/scripts/wakeword_training.py
@@ -73,6 +73,32 @@
)
+def unprivileged_container_args() -> tuple[str, ...]:
+ """Run the container as the invoking user with caches off the read-only rootfs.
+
+ The image declares no ``USER``, so without this the container is uid 0 while
+ ``--cap-drop ALL`` removes ``CAP_DAC_OVERRIDE``. Root then cannot write a
+ bind-mounted host directory it does not own, and any file it does create is
+ unreadable to the host user that has to inventory it afterwards. Matching the
+ host uid/gid keeps both directions working under the same dropped capabilities.
+
+ ``HOME`` defaults to ``/root`` on the read-only rootfs, so HuggingFace and
+ friends fail with ``Read-only file system``. Point the HOME-derived cache
+ paths at the writable tmpfs the callers already mount at ``/tmp``.
+ """
+
+ return (
+ "--user",
+ f"{os.getuid()}:{os.getgid()}",
+ "--env",
+ "HOME=/tmp/home",
+ "--env",
+ "HF_HOME=/tmp/home/hf",
+ "--env",
+ "XDG_CACHE_HOME=/tmp/home/cache",
+ )
+
+
class RunKind(StrEnum):
"""Whether an attempt may ever be considered for commercial publication."""
@@ -839,7 +865,12 @@
# The image wrapper controls the commercial stage sequence and rejects
# an upstream setup/download path. The executor deliberately exposes no
# caller override that could bypass the sealed image contract.
- from scripts.wakeword_training_image import build_stage_command
+ # Import shape differs only between the extracted archive (sibling
+ # module) and repository tests (scripts package), as in the launcher.
+ try:
+ from wakeword_training_image import build_stage_command
+ except ModuleNotFoundError:
+ from scripts.wakeword_training_image import build_stage_command
stage_command = build_stage_command(context.run_kind.value)
container_name = context.container_name
@@ -859,6 +890,7 @@
"ALL",
"--security-opt",
"no-new-privileges",
+ *unprivileged_container_args(),
"--env",
"PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True",
"--mount",
```
### Image (requires rebuild + re-pin)
```dockerfile
COPY --chmod=0644 deploy/wakeword-training/run-commercial-pipeline.py ./run-commercial-pipeline.py
# ...same for every COPY into /opt/jr-wakeword
```
Optionally move `HOME` / `HF_HOME` / `XDG_CACHE_HOME` into the Dockerfile as `ENV` and drop them from the launcher, since a rebuild is required regardless.
### Rejected alternative
Keeping uid 0 and adding `--cap-add DAC_OVERRIDE` avoids the image rebuild and does fix the write failures - but not defect 3. Root still creates `0600 root:root` files that the uid-1000 host inventory cannot hash, which is the original exit-2. `--user` is the only complete answer.
---
## Verification
With the launcher patch plus an image whose `/opt/jr-wakeword` is world-readable, a full local experiment at default umask 022 - no `chmod`, no wrapper, no workaround:
```
status : completed
stage : sealed
exit_code : 0
exports/<release>-public.tar.gz classifier.onnx, manifest.json,
evaluation.json, LICENSE,
THIRD_PARTY_NOTICES.md, SHA256SUMS
exports/<release>-private-retraining.tar.gz
```
Zero permission or read-only errors, and every artifact owned by and readable to the invoking user. Setup independently reaches exit 0 with a complete 17 GB cache (ACAV100M, 270 RIRs, 774/774 MUSAN, Piper checkpoint).
A full-scale run (50,000 steps, 10,000 samples, medium `conv_attention`, Piper batch 10) is in progress on the patched copy and has cleared generation, all three augmentation rounds, and feature extraction with no errors.
> Note: verification used a derived image (`FROM` the pinned digest + `chmod -R a+rX /opt/jr-wakeword`) rather than a full wheelhouse rebuild. Behaviourally equivalent for this purpose, but the real change belongs in the `COPY` directives so the released digest carries it.
## Suggested release gate
Extract the built `.tar.gz` / `.zip` into a clean directory as a non-root user and run it from there. Every blocker above reproduces on the first attempt, and defect 5 in particular cannot reproduce from a repo checkout.
Reproduced the two deterministic release failures from v0.2.1-jrich-3: the launcher emits root-container commands without a writable HOME/Hugging Face cache, and the extracted archive fails to resolve scripts.wakeword_training_image when launched outside its package root. A source fix is in progress on ai/jrich-gateway branch fix/wakeword-trainer-wsl-release; it will include non-root bind-mount execution, cache validation/retry, archive-layout coverage, a Windows launcher fallback, and clean-extraction release tests.
Reproduced the two deterministic release failures from v0.2.1-jrich-3: the launcher emits root-container commands without a writable HOME/Hugging Face cache, and the extracted archive fails to resolve `scripts.wakeword_training_image` when launched outside its package root. A source fix is in progress on `ai/jrich-gateway` branch `fix/wakeword-trainer-wsl-release`; it will include non-root bind-mount execution, cache validation/retry, archive-layout coverage, a Windows launcher fallback, and clean-extraction release tests.
The corrected artifacts are now published as v0.2.1-jrich-4, backed by public image digest sha256:940abc406888079c33778b0c8c34ff594b97998a3ab1c7a09faa9acf4e3d6781. The source fix is ai/jrich-gateway PR #187.
Verified gates include UID 12345 execution with the production Docker restrictions, legacy root-owned cache repair, the complete reference cache (4 singleton hashes / 270 RIR / 774 MUSAN), exact public download hashes, tar and ZIP extraction with deterministic modes, archive-only imports from another working directory, and a ZIP path containing spaces. I am leaving the issue open until PR CI/review is complete.
The corrected artifacts are now published as [v0.2.1-jrich-4](https://git.jimandkrista.com/jr-public/jrich-wakeword-trainer/releases/tag/v0.2.1-jrich-4), backed by public image digest `sha256:940abc406888079c33778b0c8c34ff594b97998a3ab1c7a09faa9acf4e3d6781`. The source fix is [ai/jrich-gateway PR #187](https://git.jimandkrista.com/ai/jrich-gateway/pulls/187).
Verified gates include UID 12345 execution with the production Docker restrictions, legacy root-owned cache repair, the complete reference cache (4 singleton hashes / 270 RIR / 774 MUSAN), exact public download hashes, tar and ZIP extraction with deterministic modes, archive-only imports from another working directory, and a ZIP path containing spaces. I am leaving the issue open until PR CI/review is complete.
runs trainer containers as the invoking WSL/Linux UID:GID so bind-mounted files remain writable and host-owned;
provides writable HOME, Hugging Face, and XDG cache paths under the mounted cache;
validates pinned, complete training assets and fails closed on partial/failed downloads;
repairs legacy root-owned cache/work files with a narrowly scoped CAP_CHOWN container;
has portable archive imports and normalized archive permissions;
includes a Windows .cmd launcher that handles PowerShell execution-policy friction;
is available as both .tar.gz and .zip.
Verification included the exact public archives, a non-root read-only/cap-drop container run, legacy ownership repair, execution from a different working directory and a path containing spaces, asset revision/count/hash validation, and the full PR CI matrix. Fresh CI is green: SQLite, Postgres (1,320 tests), web build, gateway image build, and Playwright E2E. The issue should remain open until PR #187 is human-merged and the gateway UI points at this release.
WSL portability fixes are implemented and verified in [jrich-gateway PR #187](https://git.jimandkrista.com/ai/jrich-gateway/pulls/187).
Published replacement release: [v0.2.1-jrich-4](https://git.jimandkrista.com/jr-public/jrich-wakeword-trainer/releases/tag/v0.2.1-jrich-4)
The release now:
- runs trainer containers as the invoking WSL/Linux UID:GID so bind-mounted files remain writable and host-owned;
- provides writable HOME, Hugging Face, and XDG cache paths under the mounted cache;
- validates pinned, complete training assets and fails closed on partial/failed downloads;
- repairs legacy root-owned cache/work files with a narrowly scoped CAP_CHOWN container;
- has portable archive imports and normalized archive permissions;
- includes a Windows `.cmd` launcher that handles PowerShell execution-policy friction;
- is available as both `.tar.gz` and `.zip`.
Verification included the exact public archives, a non-root read-only/cap-drop container run, legacy ownership repair, execution from a different working directory and a path containing spaces, asset revision/count/hash validation, and the full PR CI matrix. Fresh CI is green: SQLite, Postgres (1,320 tests), web build, gateway image build, and Playwright E2E. The issue should remain open until PR #187 is human-merged and the gateway UI points at this release.
Resolved and deployed. jrich-gateway PR #187 was merged as f2d244b; the complete post-merge CI matrix passed, live deployment completed at migration head 0019, gateway and web both returned HTTP 200, and rollback tag live-20260814-3 was created. The live UI now references the corrected v0.2.1-jrich-4 release.
Resolved and deployed. [jrich-gateway PR #187](https://git.jimandkrista.com/ai/jrich-gateway/pulls/187) was merged as `f2d244b`; the complete post-merge CI matrix passed, live deployment completed at migration head `0019`, gateway and web both returned HTTP 200, and rollback tag `live-20260814-3` was created. The live UI now references the corrected [v0.2.1-jrich-4 release](https://git.jimandkrista.com/jr-public/jrich-wakeword-trainer/releases/tag/v0.2.1-jrich-4).
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Release
0.2.1-jrich-3cannot complete asset setup or start training on a correctly configured host. Four separate blocking defects, plus one silent-corruption defect that would produce a bad model once the blockers are cleared.None of this is host misconfiguration and none of it is WSL-specific - a plain Linux host with the default
umask 022fails identically. Every finding below was reproduced, bisected, and then re-verified against a patched copy that trains end to end.Environment used for diagnosis: Ubuntu 24.04 on WSL2, Python 3.12.9, Docker 29.7.2, RTX 3090 (driver 591.86), NVIDIA Container Toolkit working, 855 GB free. All 28 files verified against the shipped
SHA256SUMS.1. Container runs as uid 0 with
--cap-drop ALLagainst host-owned bind mounts - BLOCKERscripts/wakeword_trainer.pypasses--cap-drop ALL. The image declares noUSER, so the container is uid 0, but--cap-drop ALLremovesCAP_DAC_OVERRIDE. Root cannot write a bind-mounted directory it does not own, and the launcher creates the cache as the invoking user at mode0755:Bisected the hardening flags individually -
--read-onlyandno-new-privilegesare both harmless here;--cap-drop ALLis the sole cause.The same pattern applies to the training stage's
/runmount inscripts/wakeword_training.py. Simulated it directly:2.
--read-onlyrootfs +HOME=/rootwith noHF_HOME- BLOCKERThe container gets
--tmpfs /tmpbutHOMEstays/rooton the read-only rootfs, and no HuggingFace cache variables are set. Every HF-backed dataset fails:After a "successful" setup the cache held 167 MB instead of ~17 GB - only the Piper checkpoint, which uses plain HTTP rather than
huggingface_hub.3. Root-created
0600files break the host-side inventory - BLOCKEREven when writes succeed, the container creates
local_dir/.cache/huggingface/trees/*.jsonasroot:rootmode0600. The host-side inventory then cannot read them:Real exit code 2. The resulting cache also could not be removed without going through a root container.
4.
/opt/jr-wakeword/*is0600 root:rootin the image - BLOCKER for the fixFixing 1-3 by running the container as the invoking user immediately hits:
Every file the Dockerfile
COPYs is mode0600:COPYpreserves build-context modes, and nothing in the Dockerfile loosens them. This was invisible while the container ran as root./opt/condais755, so setup alone survives an unprivileged uid; only the training stage breaks.This means the permission fix cannot be launcher-only - the image must be rebuilt.
5. Archive-layout import bug - BLOCKER, independent of permissions
scripts/wakeword_training.pydoes a bare:The entry point
scripts/jrich-wakeword-trainerputsscripts/onsys.path, not the archive root, so thescripts.prefix is unresolvable in an extracted archive. Training dies before any container launches:The launcher already solves this exact problem in
scripts/wakeword_trainer.py:There are exactly two
scripts.-qualified imports in the archive; one is guarded, one is not. Training therefore only ever worked from a repo checkout - the release was never exercised from its own artifact.6. Failed downloads reported as success - SILENT CORRUPTION, not a blocker
This one survives all the fixes above and is arguably the most dangerous.
livekit/wakewordcli.pydowngrades every download failure to aWARNING, then printsSetup complete!and exits 0. The runner seals whatever landed into the immutable source lock and treats it as a valid input set. Observed: a source lock over 1008 files / 17.8 GB in which the MUSAN background corpus was 227 of 774 files, with nothing in the provenance record indicating a problem.Compounding it,
_download_musan_noiseshort-circuits on any non-zero count:So one transient HTTP 429 permanently pins the corpus at a partial count, and every subsequent setup reports success. Recovery requires deleting the entire
backgrounds/directory and re-pulling all 1.1 GB. Reproduced twice (227/774, then 768/774); a wipe-and-retry loop was needed to reach 774/774.There is also no
HF_TOKENpassthrough and no retry on 429. Unauthenticated pulls rate-limit reliably at this data volume:Suggested: treat an incomplete asset set as fatal, verify counts against the expected manifest before sealing a source lock, and add token passthrough with retry/backoff.
7. Windows entry point is blocked by execution policy - end-user papercut
Under the default
RemoteSignedpolicy for CurrentUser, a downloadedstart-windows.ps1carries mark-of-the-web and "Run with PowerShell" fails before anything starts:Users can
Unblock-File, but the README says to right-click -> Run with PowerShell, which does not work on a stock machine. Consider signing the script, shipping a.cmdshim, or documenting the unblock step.Proposed fix
Launcher (this repo)
Applies cleanly to a pristine
0.2.1-jrich-3archive withpatch -p1:Image (requires rebuild + re-pin)
Optionally move
HOME/HF_HOME/XDG_CACHE_HOMEinto the Dockerfile asENVand drop them from the launcher, since a rebuild is required regardless.Rejected alternative
Keeping uid 0 and adding
--cap-add DAC_OVERRIDEavoids the image rebuild and does fix the write failures - but not defect 3. Root still creates0600 root:rootfiles that the uid-1000 host inventory cannot hash, which is the original exit-2.--useris the only complete answer.Verification
With the launcher patch plus an image whose
/opt/jr-wakewordis world-readable, a full local experiment at default umask 022 - nochmod, no wrapper, no workaround:Zero permission or read-only errors, and every artifact owned by and readable to the invoking user. Setup independently reaches exit 0 with a complete 17 GB cache (ACAV100M, 270 RIRs, 774/774 MUSAN, Piper checkpoint).
A full-scale run (50,000 steps, 10,000 samples, medium
conv_attention, Piper batch 10) is in progress on the patched copy and has cleared generation, all three augmentation rounds, and feature extraction with no errors.Suggested release gate
Extract the built
.tar.gz/.zipinto a clean directory as a non-root user and run it from there. Every blocker above reproduces on the first attempt, and defect 5 in particular cannot reproduce from a repo checkout.Reproduced the two deterministic release failures from v0.2.1-jrich-3: the launcher emits root-container commands without a writable HOME/Hugging Face cache, and the extracted archive fails to resolve
scripts.wakeword_training_imagewhen launched outside its package root. A source fix is in progress onai/jrich-gatewaybranchfix/wakeword-trainer-wsl-release; it will include non-root bind-mount execution, cache validation/retry, archive-layout coverage, a Windows launcher fallback, and clean-extraction release tests.The corrected artifacts are now published as v0.2.1-jrich-4, backed by public image digest
sha256:940abc406888079c33778b0c8c34ff594b97998a3ab1c7a09faa9acf4e3d6781. The source fix is ai/jrich-gateway PR #187.Verified gates include UID 12345 execution with the production Docker restrictions, legacy root-owned cache repair, the complete reference cache (4 singleton hashes / 270 RIR / 774 MUSAN), exact public download hashes, tar and ZIP extraction with deterministic modes, archive-only imports from another working directory, and a ZIP path containing spaces. I am leaving the issue open until PR CI/review is complete.
WSL portability fixes are implemented and verified in jrich-gateway PR #187.
Published replacement release: v0.2.1-jrich-4
The release now:
.cmdlauncher that handles PowerShell execution-policy friction;.tar.gzand.zip.Verification included the exact public archives, a non-root read-only/cap-drop container run, legacy ownership repair, execution from a different working directory and a path containing spaces, asset revision/count/hash validation, and the full PR CI matrix. Fresh CI is green: SQLite, Postgres (1,320 tests), web build, gateway image build, and Playwright E2E. The issue should remain open until PR #187 is human-merged and the gateway UI points at this release.
Resolved and deployed. jrich-gateway PR #187 was merged as
f2d244b; the complete post-merge CI matrix passed, live deployment completed at migration head0019, gateway and web both returned HTTP 200, and rollback taglive-20260814-3was created. The live UI now references the corrected v0.2.1-jrich-4 release.