feat(canary): ship Node helper with wheel + install-toolchain CLI

The fingerprint canaries' obfuscator shells out to a Node helper that
require()s javascript-obfuscator. Without this commit, a fresh
pip install decnet would land the .py modules but not the .js helper /
package.json, and there'd be no documented way to provision Node side.

* pyproject.toml - extend tool.setuptools.package-data to ship
  canary/_obfuscate_helper.js, canary/fingerprint_payload.js, and
  canary/package.json with the wheel.
* decnet/cli/canary.py - new "decnet canary-install-toolchain"
  subcommand. Resolves decnet.canary.__file__'s dir, runs
  npm install --omit=dev there, exits non-zero with a clear message
  if npm is missing or install fails. Idempotent - safe to call
  every API service start.
* deploy/decnet-api.service.j2 - non-fatal ExecStartPre that calls
  the new subcommand. Leading '-' so a missing Node toolchain only
  degrades fingerprint canaries (loud at mint time) without keeping
  the API from booting.
* tests/canary/test_cli.py - registration smoke test, missing-npm
  exit path, and a mocked-subprocess test asserting the right argv
  and cwd land on npm.

Realism cultivator already has a broad except Exception around
cultivate() in scheduler.py:195-211, so a missing toolchain on a
host running the realism tick degrades to an inert noise file with
no extra plumbing.
This commit is contained in:
2026-04-29 16:53:27 -04:00
parent 907ade9142
commit f86dc79990
4 changed files with 123 additions and 5 deletions

View File

@@ -1,8 +1,13 @@
"""``decnet canary`` — HTTP + DNS callback receiver for canary tokens.
Worker process. Mirrors the shape of :mod:`decnet.cli.webhook`: a
``@app.command(name="canary")`` Typer entry point that delegates to
:func:`decnet.canary.worker.run`.
Two entry points share this module:
* ``decnet canary`` — runs the worker process. Mirrors the shape of
:mod:`decnet.cli.webhook`. Invoked by the ``decnet-canary.service``
systemd unit so its argv must stay stable.
* ``decnet canary-install-toolchain`` — provisions the Node side of
the fingerprint-canary obfuscator. Idempotent; safe to call from
the API service unit's ``ExecStartPre``.
Not master-only — any host that hosts deckies can run its own
canary worker (the bus events stay local; the webhook worker on
@@ -11,11 +16,17 @@ in ``development/let-s-move-to-the-enumerated-pike.md``).
"""
from __future__ import annotations
import shutil
import subprocess # nosec B404 — npm exec is the whole point of the toolchain installer
from pathlib import Path
import typer
from . import utils as _utils
from .utils import console, log
_TOOLCHAIN_TIMEOUT_S = 180
def register(app: typer.Typer) -> None:
@app.command(name="canary")
@@ -40,3 +51,53 @@ def register(app: typer.Typer) -> None:
asyncio.run(run())
except KeyboardInterrupt:
console.print("\n[yellow]Canary worker stopped.[/]")
@app.command(name="canary-install-toolchain")
def canary_install_toolchain(
npm_bin: str = typer.Option(
"npm", "--npm-bin", help="Path to the npm executable. Defaults to PATH lookup.",
),
) -> None:
"""Install the Node-side toolchain used by fingerprint canaries.
Runs ``npm install --omit=dev`` under the installed ``decnet/canary/``
directory so the obfuscator's helper script can ``require()``
``javascript-obfuscator`` at mint time. Requires Node >= 18.
Idempotent: re-running on an already-installed tree is fast
(npm short-circuits when ``node_modules/`` is up-to-date).
"""
import decnet.canary as _canary_pkg
canary_dir = Path(_canary_pkg.__file__).resolve().parent
if not (canary_dir / "package.json").is_file():
console.print(
f"[red]canary package.json not found under {canary_dir}; "
"wheel may be missing the JS toolchain payload.[/]"
)
raise typer.Exit(code=2)
if shutil.which(npm_bin) is None:
console.print(
f"[red]npm executable {npm_bin!r} not found on PATH. "
"Install Node >= 18 and re-run.[/]"
)
raise typer.Exit(code=2)
console.print(
f"[cyan]installing canary toolchain[/] in {canary_dir}",
)
try:
proc = subprocess.run( # nosec B603 — argv-form, no shell, fixed cwd, npm_bin checked above
[npm_bin, "install", "--omit=dev", "--no-fund", "--no-audit"],
cwd=str(canary_dir),
capture_output=True, text=True,
timeout=_TOOLCHAIN_TIMEOUT_S, check=False,
)
except subprocess.TimeoutExpired:
console.print("[red]npm install timed out after 3 minutes[/]")
raise typer.Exit(code=3) from None
if proc.returncode != 0:
console.print(
f"[red]npm install failed rc={proc.returncode}[/]\n"
f"{proc.stderr.strip()}"
)
raise typer.Exit(code=proc.returncode)
console.print("[green]canary toolchain ready[/]")

View File

@@ -21,6 +21,10 @@ Environment=DECNET_SYSTEM_LOGS=/var/log/decnet/decnet.api.log
# hardening stays on without crippling the build path.
Environment=DOCKER_CONFIG={{ install_dir }}/.docker
Environment=BUILDX_CONFIG={{ install_dir }}/.docker/buildx
# Provision the Node toolchain used by fingerprint canaries. Non-fatal
# (leading '-'): if Node is missing the API still boots; minting a
# fingerprint canary returns a clear error at request time. Idempotent.
ExecStartPre=-{{ venv_dir }}/bin/decnet canary-install-toolchain
ExecStart={{ venv_dir }}/bin/decnet api
StandardOutput=append:/var/log/decnet/decnet.api.log
StandardError=append:/var/log/decnet/decnet.api.log
@@ -32,7 +36,7 @@ AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW
# Security Hardening
NoNewPrivileges=yes
ProtectSystem=full
ProtectHome=read-only
#ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes

View File

@@ -125,7 +125,14 @@ include = ["decnet*"]
[tool.setuptools.package-data]
# Ship docker build contexts + syslog_bridge.py as package data so they land
# in site-packages when agents install the bundle via `pip install`.
decnet = ["templates/**/*"]
# canary/*.js + canary/package.json ship the Node-side toolchain manifest
# so `decnet canary-install-toolchain` can `npm install` post-install.
decnet = [
"templates/**/*",
"canary/_obfuscate_helper.js",
"canary/fingerprint_payload.js",
"canary/package.json",
]
[tool.bandit]
# Docker build contexts — code runs inside decoy containers, not in the

View File

@@ -22,3 +22,49 @@ def test_canary_command_registered() -> None:
def test_canary_is_not_master_only() -> None:
# Agents must be able to run their own canary worker.
assert "canary" not in MASTER_ONLY_COMMANDS
def test_install_toolchain_command_registered() -> None:
runner = CliRunner()
result = runner.invoke(app, ["canary-install-toolchain", "--help"])
assert result.exit_code == 0
assert "fingerprint" in result.output.lower()
def test_install_toolchain_fails_when_npm_missing(tmp_path, monkeypatch) -> None:
"""Without npm on PATH the command exits non-zero with a clear message."""
runner = CliRunner()
# Force shutil.which to return None for our chosen sentinel name.
result = runner.invoke(
app, ["canary-install-toolchain", "--npm-bin", "/nonexistent/npm-xyz"],
)
assert result.exit_code != 0
assert "not found" in result.output.lower()
def test_install_toolchain_invokes_npm_in_canary_dir(monkeypatch) -> None:
"""Successful path: subprocess.run called with the right argv + cwd."""
import subprocess as _sp
import shutil as _shutil
from pathlib import Path
import decnet.canary as _canary_pkg
monkeypatch.setattr(_shutil, "which", lambda _x: "/usr/bin/npm-stub")
captured: dict = {}
def _fake_run(argv, **kwargs): # type: ignore[no-untyped-def]
captured["argv"] = argv
captured["cwd"] = kwargs.get("cwd")
return _sp.CompletedProcess(argv, 0, stdout="", stderr="")
monkeypatch.setattr("decnet.cli.canary.subprocess.run", _fake_run)
runner = CliRunner()
result = runner.invoke(app, ["canary-install-toolchain"])
assert result.exit_code == 0, result.output
assert captured["argv"][0] == "npm"
assert captured["argv"][1:4] == ["install", "--omit=dev", "--no-fund"]
expected_dir = str(Path(_canary_pkg.__file__).resolve().parent)
assert captured["cwd"] == expected_dir