Fix local Companion package and worker setup

This commit is contained in:
Franz Rolfsvaag 2026-07-22 14:34:29 +02:00
parent 27555e258e
commit 37dda538b3
12 changed files with 110 additions and 19 deletions

View File

@ -18,7 +18,9 @@ server offers consent-gated model download/load controls. The Transcription
settings now render inside the shared Lumi shell, and Admin shows a compact settings now render inside the shared Lumi shell, and Admin shows a compact
Companion health/download section beneath its shortcut cards. A checksum-pinned Companion health/download section beneath its shortcut cards. A checksum-pinned
self-contained Windows bundle includes a one-time pairing file and pairs after self-contained Windows bundle includes a one-time pairing file and pairs after
extraction without a separate import workflow. extraction without a separate import workflow. The pinned CPU whisper worker now
has a reproducible Windows build/install script and is discovered automatically;
the production CUDA build and target benchmark remain pending below.
Release-blocking work remains: Release-blocking work remains:

View File

@ -30,7 +30,7 @@ The repository contains a pinned native whisper.cpp rolling-window worker and a
2. Use **Download Companion** in Admin or Plugins > Transcription. Lumi downloads and checksum-verifies the pinned Windows artifact, adds a short-lived single-use pairing file, and returns a private ZIP. 2. Use **Download Companion** in Admin or Plugins > Transcription. Lumi downloads and checksum-verifies the pinned Windows artifact, adds a short-lived single-use pairing file, and returns a private ZIP.
3. On the Windows streaming computer, extract the complete ZIP and start `Lumi.Companion.App.exe` within 15 minutes. Companion discovers the adjacent pairing file, exchanges it once, stores the credential with current-user DPAPI, and removes the pairing file after success. 3. On the Windows streaming computer, extract the complete ZIP and start `Lumi.Companion.App.exe` within 15 minutes. Companion discovers the adjacent pairing file, exchanges it once, stores the credential with current-user DPAPI, and removes the pairing file after success.
4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install. 4. Install a pinned runtime/model only after explicit confirmation. `small.en` is recommended; `small.en-q5_1` and `base.en` are fallbacks. Every artifact is checksum-verified before install.
5. Configure `LUMI_TRANSCRIPTION_WORKER` with the supervised streaming-worker executable once that worker is built for the target host. 5. Build the supervised worker with `plugins/lumi_transcription/scripts/build-worker.ps1`. Lumi discovers the installed CUDA worker first and the CPU worker second. `LUMI_TRANSCRIPTION_WORKER` remains an explicit override for nonstandard installations.
The Avalonia tray UI, self-contained paired ZIP, shared Lumi settings shell, Admin summary, and host-side model download/load controls now exist. The ZIP is not code-signed, so Windows may warn. The normal signed installer, managed bridge install/repair, live OBS source enumeration, and measured model benchmark wizard are not complete yet. The Avalonia tray UI, self-contained paired ZIP, shared Lumi settings shell, Admin summary, and host-side model download/load controls now exist. The ZIP is not code-signed, so Windows may warn. The normal signed installer, managed bridge install/repair, live OBS source enumeration, and measured model benchmark wizard are not complete yet.

View File

@ -14,7 +14,7 @@ editable: false
Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions. Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.
## Metadata ## Metadata
Plugin ID: lumi_transcription Plugin ID: lumi_transcription
Version: 0.1.0-experimental.2 Version: 0.1.0-experimental.3
Default state: enabled Default state: enabled
## Web Routes ## Web Routes
- /plugins/lumi_transcription - /plugins/lumi_transcription

View File

@ -6,22 +6,27 @@ const { ArtifactManager } = require("../models/artifact_manager");
class CompanionPackageService { class CompanionPackageService {
constructor(root, manifest, options = {}) { constructor(root, manifest, options = {}) {
this.root = root; this.root = root;
this.manifest = manifest; this.manifestPath = typeof manifest === "string" ? manifest : null;
this.manifest = typeof manifest === "string" ? null : manifest;
this.artifacts = new ArtifactManager(root, { fetch: options.fetch }); this.artifacts = new ArtifactManager(root, { fetch: options.fetch });
fs.mkdirSync(root, { recursive: true }); fs.mkdirSync(root, { recursive: true });
} }
entry() { return this.manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; } currentManifest() { return this.manifestPath ? JSON.parse(fs.readFileSync(this.manifestPath, "utf8")) : this.manifest; }
entry(manifest = this.currentManifest()) { return manifest?.artifacts?.find((entry) => entry.platform === "win32" && entry.architecture === "x64") || null; }
status() { status() {
const entry = this.entry(); const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." }; if (!entry) return { available: false, installed: false, valid: false, reason: "No Windows Companion artifact is configured." };
const artifact = this.artifacts.status(entry); const artifact = this.artifacts.status(entry);
return { available: true, version: this.manifest.version, artifact: entry.id, ...artifact }; return { available: true, version: manifest.version, artifact: entry.id, ...artifact };
} }
async build(pairing) { async build(pairing) {
const entry = this.entry(); const manifest = this.currentManifest();
const entry = this.entry(manifest);
if (!entry) throw new Error("No Windows Companion artifact is configured."); if (!entry) throw new Error("No Windows Companion artifact is configured.");
let status = this.artifacts.status(entry); let status = this.artifacts.status(entry);
if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true }); if (!status.valid) status = await this.artifacts.download(entry, { confirmed: true });
@ -47,7 +52,7 @@ class CompanionPackageService {
"Do not share this ZIP. Its pairing package works once and expires after 15 minutes.", "Do not share this ZIP. Its pairing package works once and expires after 15 minutes.",
"Windows may warn because this experimental build is not code-signed yet.", "" "Windows may warn because this experimental build is not code-signed yet.", ""
].join("\r\n"), "utf8")); ].join("\r\n"), "utf8"));
return { buffer: output.toBuffer(), filename: `Lumi-Companion-${this.manifest.version}-paired.zip`, pairingName }; return { buffer: output.toBuffer(), filename: `Lumi-Companion-${manifest.version}-paired.zip`, pairingName };
} }
} }

View File

@ -25,6 +25,7 @@ class ArtifactManager {
if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve)); if (!handle.write(chunk)) await new Promise((resolve) => handle.once("drain", resolve));
} }
await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve())); await new Promise((resolve, reject) => handle.end((error) => error ? reject(error) : resolve()));
if (Number.isSafeInteger(entry.bytes) && fs.statSync(partial).size !== entry.bytes) throw new Error(`Downloaded size mismatch for ${entry.id}.`);
const actual = sha256File(partial); const actual = sha256File(partial);
if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`); if (actual !== entry.sha256) throw new Error(`Checksum mismatch for ${entry.id}.`);
fs.renameSync(partial, target); fs.renameSync(partial, target);

View File

@ -4,4 +4,15 @@ const ROOT = path.resolve(__dirname, "..");
const DATA = path.join(ROOT, "data"); const DATA = path.join(ROOT, "data");
function ensureDataDirs() { for (const name of ["logs", "models", "runtime", "tmp"]) fs.mkdirSync(path.join(DATA, name), { recursive: true }); } function ensureDataDirs() { for (const name of ["logs", "models", "runtime", "tmp"]) fs.mkdirSync(path.join(DATA, name), { recursive: true }); }
function dataPath(...parts) { const target = path.resolve(DATA, ...parts); if (target !== DATA && !target.startsWith(`${DATA}${path.sep}`)) throw new Error("Path escapes transcription plugin data."); return target; } function dataPath(...parts) { const target = path.resolve(DATA, ...parts); if (target !== DATA && !target.startsWith(`${DATA}${path.sep}`)) throw new Error("Path escapes transcription plugin data."); return target; }
module.exports = { ROOT, DATA, ensureDataDirs, dataPath }; function resolveWorkerExecutable(override = "") {
const requested = String(override || "").trim();
if (requested) return fs.existsSync(requested) ? path.resolve(requested) : requested;
const executable = process.platform === "win32" ? "lumi-whisper-worker.exe" : "lumi-whisper-worker";
const candidates = [
dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cuda" : `${process.platform}-${process.arch}-cuda`, "bin", executable),
dataPath("runtime", "worker", process.platform === "win32" ? "windows-x64-cpu" : `${process.platform}-${process.arch}`, "bin", executable),
path.join(ROOT, "backend", "transcription", "worker-native", "build", executable)
];
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || "";
}
module.exports = { ROOT, DATA, ensureDataDirs, dataPath, resolveWorkerExecutable };

View File

@ -30,7 +30,7 @@ class WhisperWorkerSupervisor extends EventEmitter {
} }
start() { start() {
if (this.child) return; if (this.child) return;
if (!this.executable) throw new Error("Whisper worker executable is not configured."); if (!this.executable) throw coded("WORKER_NOT_CONFIGURED", "The whisper.cpp worker is not installed. Build or install the Lumi transcription runtime, then retry.");
this.stopping = false; this.stopping = false;
this.state = "starting"; this.state = "starting";
const child = this.spawn(this.executable, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); const child = this.spawn(this.executable, this.args, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
@ -114,8 +114,10 @@ class WhisperCppServerProvider extends TranscriptionProvider {
const timer = setTimeout(() => { cleanup(); reject(new Error("The whisper.cpp worker did not confirm model loading in time.")); }, 30000); const timer = setTimeout(() => { cleanup(); reject(new Error("The whisper.cpp worker did not confirm model loading in time.")); }, 30000);
const onLoaded = (event) => { cleanup(); resolve(event); }; const onLoaded = (event) => { cleanup(); resolve(event); };
const onError = (event) => { cleanup(); reject(Object.assign(new Error(event.message || "The model could not be loaded."), { code: event.code })); }; const onError = (event) => { cleanup(); reject(Object.assign(new Error(event.message || "The model could not be loaded."), { code: event.code })); };
const cleanup = () => { clearTimeout(timer); this.off("model_loaded", onLoaded); this.off("model_error", onError); }; const onProviderError = (error) => { cleanup(); reject(error); };
const cleanup = () => { clearTimeout(timer); this.off("model_loaded", onLoaded); this.off("model_error", onError); this.off("provider_error", onProviderError); };
this.once("model_loaded", onLoaded); this.once("model_error", onError); this.once("model_loaded", onLoaded); this.once("model_error", onError);
this.once("provider_error", onProviderError);
}); });
this.worker.send({ type: "load_model", model }); this.worker.send({ type: "load_model", model });
const result = await loaded; const result = await loaded;
@ -132,4 +134,6 @@ class WhisperCppServerProvider extends TranscriptionProvider {
async stop() { this.sessions.clear(); await this.worker.stop(); } async stop() { this.sessions.clear(); await this.worker.stop(); }
} }
function coded(code, message) { return Object.assign(new Error(message), { code }); }
module.exports = { TranscriptionProvider, WhisperWorkerSupervisor, WhisperCppServerProvider }; module.exports = { TranscriptionProvider, WhisperWorkerSupervisor, WhisperCppServerProvider };

View File

@ -10,3 +10,11 @@ cmake --build build
``` ```
The worker keeps at most six seconds per source, attempts an incremental decode around every 600 ms during speech, and finalizes after roughly 750 ms of measured silence. The server-side stabilizer remains authoritative for stable-prefix reconciliation and obsolete-revision suppression. The worker keeps at most six seconds per source, attempts an incremental decode around every 600 ms during speech, and finalizes after roughly 750 ms of measured silence. The server-side stabilizer remains authoritative for stable-prefix reconciliation and obsolete-revision suppression.
On Windows, the repository setup script builds into a short temporary path and installs the executable where the plugin discovers it automatically:
```powershell
powershell -ExecutionPolicy Bypass -File plugins/lumi_transcription/scripts/build-worker.ps1
```
Pass `-Cuda` only after installing a compatible NVIDIA CUDA toolkit. Lumi prefers the installed CUDA worker and falls back to the CPU worker; `LUMI_TRANSCRIPTION_WORKER` remains an explicit override.

View File

@ -12,10 +12,9 @@ const { JsonlDiagnosticLog } = require("./backend/logs/jsonl_log");
const { ArtifactManager } = require("./backend/models/artifact_manager"); const { ArtifactManager } = require("./backend/models/artifact_manager");
const { SessionCoordinator } = require("./backend/sessions/session_coordinator"); const { SessionCoordinator } = require("./backend/sessions/session_coordinator");
const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend/transcription/provider"); const { WhisperWorkerSupervisor, WhisperCppServerProvider } = require("./backend/transcription/provider");
const { ensureDataDirs, dataPath } = require("./backend/paths"); const { ensureDataDirs, dataPath, resolveWorkerExecutable } = require("./backend/paths");
const modelManifest = require("./models_manifest.json"); const modelManifest = require("./models_manifest.json");
const runtimeManifest = require("./runtime_manifest.json"); const runtimeManifest = require("./runtime_manifest.json");
const companionManifest = require("./companion_manifest.json");
const manifest = require("./plugin.json"); const manifest = require("./plugin.json");
const PLUGIN_ID = "lumi_transcription"; const PLUGIN_ID = "lumi_transcription";
@ -31,7 +30,7 @@ module.exports = {
const cleanupTimer = setInterval(() => diagnosticLog.cleanup(), 60 * 60 * 1000); const cleanupTimer = setInterval(() => diagnosticLog.cleanup(), 60 * 60 * 1000);
cleanupTimer.unref?.(); cleanupTimer.unref?.();
const supervisor = new WhisperWorkerSupervisor({ const supervisor = new WhisperWorkerSupervisor({
executable: process.env.LUMI_TRANSCRIPTION_WORKER || "", executable: resolveWorkerExecutable(process.env.LUMI_TRANSCRIPTION_WORKER),
args: process.env.LUMI_TRANSCRIPTION_WORKER_ARGS ? JSON.parse(process.env.LUMI_TRANSCRIPTION_WORKER_ARGS) : [] args: process.env.LUMI_TRANSCRIPTION_WORKER_ARGS ? JSON.parse(process.env.LUMI_TRANSCRIPTION_WORKER_ARGS) : []
}); });
supervisor.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", ...entry })); supervisor.on("diagnostic", (entry) => diagnosticLog.append({ kind: "worker", ...entry }));
@ -47,7 +46,7 @@ module.exports = {
const models = new ArtifactManager(dataPath("models")); const models = new ArtifactManager(dataPath("models"));
const runtimeArchives = new ArtifactManager(dataPath("tmp")); const runtimeArchives = new ArtifactManager(dataPath("tmp"));
const runtimes = new ArtifactManager(dataPath("runtime")); const runtimes = new ArtifactManager(dataPath("runtime"));
const companionPackages = new CompanionPackageService(dataPath("companion"), companionManifest); const companionPackages = new CompanionPackageService(dataPath("companion"), path.join(__dirname, "companion_manifest.json"));
const selectedModelId = revisions.list().selected_model_id?.value; const selectedModelId = revisions.list().selected_model_id?.value;
const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId); const selectedModel = modelManifest.models.find((entry) => entry.id === selectedModelId);
if (supervisor.executable && selectedModel) { if (supervisor.executable && selectedModel) {
@ -140,7 +139,7 @@ module.exports = {
revisions.apply([{ key: "selected_model_id", value: entry.id, base_revision: current?.revision || 0 }], req.session.user.id); revisions.apply([{ key: "selected_model_id", value: entry.id, base_revision: current?.revision || 0 }], req.session.user.id);
res.json({ ok: true, status: providerStatus }); res.json({ ok: true, status: providerStatus });
} }
catch (error) { res.status(500).json({ ok: false, error: error.message }); } catch (error) { res.status(error.code === "WORKER_NOT_CONFIGURED" ? 409 : 500).json({ ok: false, code: error.code || "MODEL_LOAD_FAILED", error: error.message }); }
}); });
router.post("/api/runtime/:id/install", requireAdmin, async (req, res) => { router.post("/api/runtime/:id/install", requireAdmin, async (req, res) => {
const entry = runtimeManifest.artifacts.find((candidate) => candidate.id === req.params.id); const entry = runtimeManifest.artifacts.find((candidate) => candidate.id === req.params.id);

View File

@ -1,7 +1,7 @@
{ {
"id": "lumi_transcription", "id": "lumi_transcription",
"name": "Lumi Transcription", "name": "Lumi Transcription",
"version": "0.1.0-experimental.2", "version": "0.1.0-experimental.3",
"description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.", "description": "Server-hosted whisper.cpp transcription for Lumi Companion and OBS closed captions.",
"main": "index.js", "main": "index.js",
"channel": "experimental", "channel": "experimental",

View File

@ -0,0 +1,46 @@
param(
[switch]$Cuda
)
$ErrorActionPreference = "Stop"
$pluginRoot = Split-Path -Parent $PSScriptRoot
$sourceRoot = Join-Path $pluginRoot "backend\transcription\worker-native"
$backend = if ($Cuda) { "cuda" } else { "cpu" }
$buildRoot = Join-Path $env:TEMP "lumi-whisper-worker-$backend"
$targetRoot = Join-Path $pluginRoot "data\runtime\worker\windows-x64-$backend\bin"
function Find-Tool([string]$name) {
$command = Get-Command $name -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
$candidate = Get-ChildItem (Join-Path $env:APPDATA "Python\Python*\Scripts\$name") -ErrorAction SilentlyContinue |
Sort-Object FullName -Descending |
Select-Object -First 1
if ($candidate) { return $candidate.FullName }
throw "$name was not found. Install CMake and Ninja before building the transcription worker."
}
$cmake = Find-Tool "cmake.exe"
$ninja = Find-Tool "ninja.exe"
$vcvars = Get-ChildItem "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\*\VC\Auxiliary\Build\vcvars64.bat" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (!$vcvars) { throw "Visual Studio 2022 C++ Build Tools were not found." }
New-Item -ItemType Directory -Force -Path $buildRoot, $targetRoot | Out-Null
$cudaValue = if ($Cuda) { "ON" } else { "OFF" }
$buildScript = Join-Path $buildRoot "build-worker.cmd"
@(
"@echo off",
('call "{0}" >nul' -f $vcvars.FullName),
('"{0}" -S "{1}" -B "{2}" -G Ninja -DLUMI_WHISPER_CUDA={3} -DCMAKE_BUILD_TYPE=Release -DCMAKE_MAKE_PROGRAM="{4}"' -f $cmake, $sourceRoot, $buildRoot, $cudaValue, $ninja),
"if errorlevel 1 exit /b %errorlevel%",
('"{0}" --build "{1}" --config Release' -f $cmake, $buildRoot),
"exit /b %errorlevel%"
) | Set-Content -Encoding Ascii $buildScript
$commandProcessor = Join-Path $env:SystemRoot "System32\cmd.exe"
$process = Start-Process -FilePath $commandProcessor -ArgumentList "/d /c $buildScript" -Wait -PassThru -NoNewWindow
if ($process.ExitCode -ne 0) { throw "Worker build failed with exit code $($process.ExitCode)." }
$worker = Join-Path $buildRoot "lumi-whisper-worker.exe"
if (!(Test-Path $worker)) { throw "The build completed without producing lumi-whisper-worker.exe." }
Copy-Item -Force $worker (Join-Path $targetRoot "lumi-whisper-worker.exe")
Write-Host "Installed Lumi whisper worker ($backend) in $targetRoot"

View File

@ -13,6 +13,7 @@ const { DeviceStore } = require("../backend/companion/device_store");
const { normalizeHost } = require("../backend/companion/device_store"); const { normalizeHost } = require("../backend/companion/device_store");
const { CompanionPackageService, safeArchivePath } = require("../backend/companion/package_service"); const { CompanionPackageService, safeArchivePath } = require("../backend/companion/package_service");
const { CompanionGateway } = require("../backend/companion/gateway"); const { CompanionGateway } = require("../backend/companion/gateway");
const { resolveWorkerExecutable } = require("../backend/paths");
const protocol = require("../backend/companion/protocol"); const protocol = require("../backend/companion/protocol");
const { RevisionStore } = require("../backend/config/revision_store"); const { RevisionStore } = require("../backend/config/revision_store");
const { JsonlDiagnosticLog, sanitize } = require("../backend/logs/jsonl_log"); const { JsonlDiagnosticLog, sanitize } = require("../backend/logs/jsonl_log");
@ -30,6 +31,7 @@ async function run() {
verifyProtocol(); verifyProtocol();
verifyPairingAndRevocation(); verifyPairingAndRevocation();
verifyLocalhostTransportPolicy(); verifyLocalhostTransportPolicy();
verifyWorkerResolution(temp);
verifyRevisions(); verifyRevisions();
verifyQueues(); verifyQueues();
verifyStabilization(); verifyStabilization();
@ -94,6 +96,13 @@ function verifyLocalhostTransportPolicy() {
db.close(); db.close();
} }
function verifyWorkerResolution(temp) {
const executable = path.join(temp, process.platform === "win32" ? "worker.exe" : "worker");
fs.writeFileSync(executable, "worker");
assert.equal(resolveWorkerExecutable(executable), path.resolve(executable));
assert.throws(() => new WhisperWorkerSupervisor({ executable: "" }).start(), (error) => error.code === "WORKER_NOT_CONFIGURED");
}
function verifyRevisions() { function verifyRevisions() {
const db = new Database(":memory:"); const db = new Database(":memory:");
const store = new RevisionStore(db, { now: () => 1234 }); const store = new RevisionStore(db, { now: () => 1234 });
@ -277,7 +286,7 @@ async function verifyCompanionPackage(temp) {
zip.writeZip(sourcePath); zip.writeZip(sourcePath);
const manifest = { version: "test", artifacts: [{ const manifest = { version: "test", artifacts: [{
id: "windows-x64", platform: "win32", architecture: "x64", filename: "companion.zip", id: "windows-x64", platform: "win32", architecture: "x64", filename: "companion.zip",
entrypoint: "Lumi.Companion.App.exe", url: "https://example.invalid/companion.zip", sha256: sha256File(sourcePath) entrypoint: "Lumi.Companion.App.exe", url: "https://example.invalid/companion.zip", sha256: sha256File(sourcePath), bytes: fs.statSync(sourcePath).size
}] }; }] };
const service = new CompanionPackageService(root, manifest); const service = new CompanionPackageService(root, manifest);
const bundle = await service.build({ pairing_id: "pairing", bootstrap: { format: "lumi-companion-bootstrap-v1", token: "single-use" } }); const bundle = await service.build({ pairing_id: "pairing", bootstrap: { format: "lumi-companion-bootstrap-v1", token: "single-use" } });
@ -285,6 +294,12 @@ async function verifyCompanionPackage(temp) {
assert.equal(output.readAsText("Lumi.Companion.App.exe"), "portable-app"); assert.equal(output.readAsText("Lumi.Companion.App.exe"), "portable-app");
assert.match(output.readAsText("lumi-companion-pairing.lumi-pairing.json"), /single-use/); assert.match(output.readAsText("lumi-companion-pairing.lumi-pairing.json"), /single-use/);
assert.match(output.readAsText("START-HERE.txt"), /works once/i); assert.match(output.readAsText("START-HERE.txt"), /works once/i);
const manifestPath = path.join(root, "manifest.json");
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
const liveManifestService = new CompanionPackageService(root, manifestPath);
assert.equal(liveManifestService.status().version, "test");
fs.writeFileSync(manifestPath, JSON.stringify({ ...manifest, version: "refreshed" }));
assert.equal(liveManifestService.status().version, "refreshed");
assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i); assert.throws(() => safeArchivePath("../escape.exe"), /unsafe path/i);
} }