From 795345e5fd7974a1f5227d507a58bb3ed75eafd5 Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Mon, 13 Apr 2026 16:38:16 +0200 Subject: Compute OIDC auth, async Horde agents, and orchestrator improvements (#913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the Horde agent subsystem from synchronous per-thread I/O to an async ASIO-driven architecture, plus provisioner scale-down with graceful draining, OIDC authentication, scheduler improvements, and dashboard UI for provisioner control. ### Async Horde Agent Rewrite - Replace synchronous `HordeAgent` (one thread per agent, blocking I/O) with `AsyncHordeAgent` — an ASIO state machine running on a shared `io_context` thread pool - Replace `TcpComputeTransport`/`AesComputeTransport` with `AsyncTcpComputeTransport`/`AsyncAesComputeTransport` - Replace `AgentMessageChannel` with `AsyncAgentMessageChannel` using frame queuing and ASIO timers - Delete `ComputeBuffer` and `ComputeChannel` ring-buffer classes (no longer needed) ### Provisioner Drain / Scale-Down - `HordeProvisioner` can now drain agents when target core count is lowered: queries each agent's `/compute/session/status` for workload, selects candidates by largest-fit/lowest-workload, and sends `/compute/session/drain` - Configurable `--horde-drain-grace-period` (default 300s) before force-kill - Implement `IProvisionerStateProvider` interface to expose provisioner state to the orchestrator HTTP layer - Forward `--coordinator-session`, `--provision-clean`, and `--provision-tracehost` through both Horde and Nomad provisioners to spawned workers ### OIDC Authentication - `HordeClient` accepts an `AccessTokenProvider` (refreshable token function) as alternative to static `--horde-token` - Wire up `OidcToken.exe` auto-discovery via `httpclientauth::CreateFromOidcTokenExecutable` with `--HordeUrl` mode - New `--horde-oidctoken-exe-path` CLI option for explicit path override ### Orchestrator & Scheduler - Orchestrator generates a session ID at startup; workers include `coordinator_session` in announcements so the orchestrator can reject stale-session workers - New `Rejected` action state — when a remote runner declines at capacity, the action is rescheduled without retry count increment - Reduce scheduler lock contention: snapshot pending actions under shared lock, sort/trim outside the lock - Parallelize remote action submission across runners via `WorkerThreadPool` with slow-submit warnings - New action field `FailureReason` populated by all runner types (exit codes, sandbox failures, exceptions) - New endpoints: `session/drain`, `session/status`, `session/sunset`, `provisioner/status`, `provisioner/target` ### Remote Execution - Eager-attach mode for `RemoteHttpRunner` — bundles all attachments upfront in a `CbPackage` for single-roundtrip submits - Track in-flight submissions to prevent over-queuing - Show remote runner hostname in `GetDisplayName()` - `--announce-url` to override the endpoint announced to the coordinator (e.g. relay-visible address) ### Frontend Dashboard - Delete standalone `compute.html` (925 lines) and `orchestrator.html` (669 lines), consolidated into JS page modules - Add provisioner panel to orchestrator dashboard: target/active/estimated core counts, draining agent count - Editable target-cores input with debounced POST to `/orch/provisioner/target` - Per-agent provisioning status badges (active / draining / deallocated) in the agents table - Active vs total CPU counts in agents summary row ### CLI - New `zen compute record-start` / `record-stop` subcommands - `zen exec` progress bar with submit and completion phases, atomic work counters, `--progress` mode (Pretty/Plain/Quiet) ### Other - `DataDir` supports environment variable expansion - Worker manifest validation checks for `worker.zcb` marker to detect incomplete cached directories - Linux/Mac runners `nice(5)` child processes to avoid starving the main server - `ComputeService::SetShutdownCallback` wired to `RequestExit` via `session/sunset` - Curl HTTP client logs effective URL on failure - `MachineInfo` carries `Pool` and `Mode` from Horde response - Horde bundle creation includes `.pdb` on Windows --- src/zen/zen.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 3277eb856..bbf6b4f8a 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -9,6 +9,7 @@ #include "cmds/bench_cmd.h" #include "cmds/builds_cmd.h" #include "cmds/cache_cmd.h" +#include "cmds/compute_cmd.h" #include "cmds/copy_cmd.h" #include "cmds/dedup_cmd.h" #include "cmds/exec_cmd.h" @@ -588,7 +589,8 @@ main(int argc, char** argv) DropCommand DropCmd; DropProjectCommand ProjectDropCmd; #if ZEN_WITH_COMPUTE_SERVICES - ExecCommand ExecCmd; + ComputeCommand ComputeCmd; + ExecCommand ExecCmd; #endif // ZEN_WITH_COMPUTE_SERVICES ExportOplogCommand ExportOplogCmd; FlushCommand FlushCmd; @@ -649,6 +651,7 @@ main(int argc, char** argv) {DownCommand::Name, &DownCmd, DownCommand::Description}, {DropCommand::Name, &DropCmd, DropCommand::Description}, #if ZEN_WITH_COMPUTE_SERVICES + {ComputeCommand::Name, &ComputeCmd, ComputeCommand::Description}, {ExecCommand::Name, &ExecCmd, ExecCommand::Description}, #endif {GcStatusCommand::Name, &GcStatusCmd, GcStatusCommand::Description}, -- cgit v1.2.3 From 3d59b5d7036c35fe484d052ff32dbdc9d0a75cf7 Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 13 Apr 2026 19:17:09 +0200 Subject: fix utf characters in source code (#953) --- src/zen/zen.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index bbf6b4f8a..0229db4a8 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -369,7 +369,7 @@ ZenCmdWithSubCommands::Run(const ZenCliOptions& GlobalOptions, int argc, char** } } - // Parse subcommand args permissively — unrecognised options are collected + // Parse subcommand args permissively - unrecognised options are collected // and forwarded to the parent parser so that parent options (e.g. --path) // can appear after the subcommand name on the command line. std::vector SubUnmatched; -- cgit v1.2.3 From c7c59cdc5a70bfd6e5f66f3b032ea3f8f6b4d12a Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 20 Apr 2026 07:27:35 +0200 Subject: builds cmd refactor (#975) - Bugfix: `builds download` partial-block fetch decisions now account for build storage host latency - Bugfix: Transfer rate displays in `builds` commands now smooth correctly - Split `buildstorageoperations.cpp` (8.5k lines) into per-operation TUs: buildinspect, buildprimecache, buildstorageresolve, buildupdatefolder, builduploadfolder, buildvalidatebuildpart; stats moved to buildstoragestats.h. - FilteredRate extracted to zenutil. - BuildsCommand shared state consolidated into a BuildsConfiguration struct; subcommands inherit from BuildsSubCmdBase holding a `const BuildsConfiguration&` instead of a `BuildsCommand&`. - `ProgressBar` renamed to `ConsoleProgressBar`; mode enum (`ConsoleProgressMode`) lifted to namespace scope; `PushLogOperation`/`PopLogOperation`/`ForceLinebreak` promoted to virtuals on `ProgressBase`. - Free-function wrappers (`UploadFolder`, `DownloadFolder`, `ValidateBuildPart`) added around the existing operation classes so callers stop reimplementing setup + stats logging. --- src/zen/zen.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 0229db4a8..a09f923fc 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -56,7 +56,7 @@ #include #include -#include "progressbar.h" +#include "consoleprogress.h" #if ZEN_WITH_TESTS # include -- cgit v1.2.3 From dde485f0b777a62d65d817906a8e05caf2d18bc3 Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Mon, 20 Apr 2026 10:00:10 +0200 Subject: consolidate cache commands into `cache` subcommand (#978) Consolidate the scattered cache-related top-level commands into a single `zen cache ` command tree, keeping the old names as hidden deprecated aliases so any existing scripts keep working. ## Motivation `zen` has accumulated a flat list of cache-adjacent commands (`cache-info`, `cache-stats`, `cache-details`, `cache-gen`, `cache-get`, `drop`, `rpc-record-start/stop`, `rpc-record-replay`). Each one re-declares `--hosturl` parsing and host resolution, and there is no natural home for new cache tooling. Grouping them under `cache` gives a consistent UX and a shared base class to hang common options off of. ## Changes ### Subcommand consolidation - Moved into `cache ` form: - `cache info`, `cache stats`, `cache details`, `cache gen`, `cache get`, `cache drop` - `cache record ` / `cache record stop` (formerly `rpc-record-start` / `rpc-record-stop`) - `cache replay` (formerly `rpc-record-replay`) - All old top-level names remain as deprecated aliases and forward through a shared legacy-shim dispatcher that rewrites `argv` and re-enters the new dispatcher, so behavior is byte-identical for existing callers. - Deprecated aliases are now hidden from the top-level `zen --help` listing (new `ZenCmdBase::IsHidden()` + `DeprecatedCacheStoreCommand` base). They still dispatch normally; `zen cache --help` is the canonical discovery surface. ### Shared base class - New `CacheSubCmdBase` owns the `--hosturl` option and `ResolveHost()` logic, eliminating the copy/pasted block at the top of every `Run()`. ### Output format - Added `--yaml` to `cache info`, `cache stats`, and `cache details` (negotiated server-side via `Accept: text/yaml`). `cache details` now rejects `--csv --yaml` combined. ### Hardening - `cache gen`: bounds-check requested sizes before allocating. - `cache replay`: validate `--stride` / `--offset` and fix progress-math overflow edge cases. --- src/zen/zen.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index a09f923fc..553fac155 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -17,7 +17,6 @@ #include "cmds/info_cmd.h" #include "cmds/print_cmd.h" #include "cmds/projectstore_cmd.h" -#include "cmds/rpcreplay_cmd.h" #include "cmds/run_cmd.h" #include "cmds/serve_cmd.h" #include "cmds/service_cmd.h" @@ -575,6 +574,7 @@ main(int argc, char** argv) AttachCommand AttachCmd; BenchCommand BenchCmd; BuildsCommand BuildsCmd; + CacheCommand CacheCmd; CacheDetailsCommand CacheDetailsCmd; CacheGetCommand CacheGetCmd; CacheGenerateCommand CacheGenerateCmd; @@ -640,6 +640,7 @@ main(int argc, char** argv) {AttachCommand::Name, &AttachCmd, AttachCommand::Description}, {BenchCommand::Name, &BenchCmd, BenchCommand::Description}, {BuildsCommand::Name, &BuildsCmd, BuildsCommand::Description}, + {CacheCommand::Name, &CacheCmd, CacheCommand::Description}, {CacheDetailsCommand::Name, &CacheDetailsCmd, CacheDetailsCommand::Description}, {CacheInfoCommand::Name, &CacheInfoCmd, CacheInfoCommand::Description}, {CacheGetCommand::Name, &CacheGetCmd, CacheGetCommand::Description}, @@ -876,6 +877,11 @@ main(int argc, char** argv) for (const CommandInfo& CmdInfo : Commands) { + if (CmdInfo.Cmd->IsHidden()) + { + continue; + } + ZenCmdCategory& Category = CmdInfo.Cmd->CommandCategory(); Categories[Category.Name] = &Category; -- cgit v1.2.3 From aed68370f182b31efd9dce116ab1c9d33ebc35cb Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Mon, 20 Apr 2026 13:14:54 +0200 Subject: zen: remove unused 'copy' and 'run' subcommands (#986) These CLI commands are no longer useful and have been dropped from the zen client. --- src/zen/zen.cpp | 6 ------ 1 file changed, 6 deletions(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 553fac155..542788ca4 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -10,14 +10,12 @@ #include "cmds/builds_cmd.h" #include "cmds/cache_cmd.h" #include "cmds/compute_cmd.h" -#include "cmds/copy_cmd.h" #include "cmds/dedup_cmd.h" #include "cmds/exec_cmd.h" #include "cmds/hub_cmd.h" #include "cmds/info_cmd.h" #include "cmds/print_cmd.h" #include "cmds/projectstore_cmd.h" -#include "cmds/run_cmd.h" #include "cmds/serve_cmd.h" #include "cmds/service_cmd.h" #include "cmds/status_cmd.h" @@ -580,7 +578,6 @@ main(int argc, char** argv) CacheGenerateCommand CacheGenerateCmd; CacheInfoCommand CacheInfoCmd; CacheStatsCommand CacheStatsCmd; - CopyCommand CopyCmd; CopyStateCommand CopyStateCmd; CreateOplogCommand CreateOplogCmd; CreateProjectCommand CreateProjectCmd; @@ -614,7 +611,6 @@ main(int argc, char** argv) RpcReplayCommand RpcReplayCmd; RpcStartRecordingCommand RpcStartRecordingCmd; RpcStopRecordingCommand RpcStopRecordingCmd; - RunCommand RunCmd; ScrubCommand ScrubCmd; ServeCommand ServeCmd; StatusCommand StatusCmd; @@ -646,7 +642,6 @@ main(int argc, char** argv) {CacheGetCommand::Name, &CacheGetCmd, CacheGetCommand::Description}, {CacheGenerateCommand::Name, &CacheGenerateCmd, CacheGenerateCommand::Description}, {CacheStatsCommand::Name, &CacheStatsCmd, CacheStatsCommand::Description}, - {CopyCommand::Name, &CopyCmd, CopyCommand::Description}, {CopyStateCommand::Name, &CopyStateCmd, CopyStateCommand::Description}, {DedupCommand::Name, &DedupCmd, DedupCommand::Description}, {DownCommand::Name, &DownCmd, DownCommand::Description}, @@ -680,7 +675,6 @@ main(int argc, char** argv) {RpcReplayCommand::Name, &RpcReplayCmd, RpcReplayCommand::Description}, {RpcStartRecordingCommand::Name, &RpcStartRecordingCmd, RpcStartRecordingCommand::Description}, {RpcStopRecordingCommand::Name, &RpcStopRecordingCmd, RpcStopRecordingCommand::Description}, - {RunCommand::Name, &RunCmd, RunCommand::Description}, {ScrubCommand::Name, &ScrubCmd, ScrubCommand::Description}, {ServeCommand::Name, &ServeCmd, ServeCommand::Description}, {StatusCommand::Name, &StatusCmd, StatusCommand::Description}, -- cgit v1.2.3 From 28a61b12d302e9e0d37d52bf1aa5d19069f3411b Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 20 Apr 2026 15:53:22 +0200 Subject: zen history command (#987) - Feature: Per-user invocation history for `zen` and `zenserver`; each startup appends a record to a JSONL file capped at the most recent 100 entries. Location: `%LOCALAPPDATA%\Epic\Zen\History\invocations.jsonl` on Windows, `~/.zen/History/invocations.jsonl` on POSIX - `zen history` opens an interactive picker; selecting a zen row re-runs it inline and forwards the exit code, selecting a zenserver row spawns it detached - `zen history --list` (`-l`) prints the table to stdout instead of showing the picker - `zen history --filter zen|zenserver` restricts the listing to one executable - `zen history --print` prints the reconstructed command line of the selected row instead of launching it - `--enable-execution-history` global option on both binaries (default `true`) to opt out per invocation - The history file is attached to Sentry crash reports (alongside the existing zenserver log) --- src/zen/zen.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 542788ca4..984e8589b 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -12,6 +12,7 @@ #include "cmds/compute_cmd.h" #include "cmds/dedup_cmd.h" #include "cmds/exec_cmd.h" +#include "cmds/history_cmd.h" #include "cmds/hub_cmd.h" #include "cmds/info_cmd.h" #include "cmds/print_cmd.h" @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -549,6 +551,8 @@ main(int argc, char** argv) { zen::InstallCrashHandler(); + zen::LogInvocation("zen", /*Mode*/ "", argc, argv, {zen::HistoryCommand::Name}); + #if ZEN_PLATFORM_WINDOWS setlocale(LC_ALL, "en_us.UTF8"); #endif // ZEN_PLATFORM_WINDOWS @@ -594,6 +598,7 @@ main(int argc, char** argv) GcCommand GcCmd; GcStatusCommand GcStatusCmd; GcStopCommand GcStopCmd; + HistoryCommand HistoryCmd; HubCommand HubCmd; ImportOplogCommand ImportOplogCmd; InfoCommand InfoCmd; @@ -653,6 +658,7 @@ main(int argc, char** argv) {GcStatusCommand::Name, &GcStatusCmd, GcStatusCommand::Description}, {GcStopCommand::Name, &GcStopCmd, GcStopCommand::Description}, {GcCommand::Name, &GcCmd, GcCommand::Description}, + {HistoryCommand::Name, &HistoryCmd, HistoryCommand::Description}, {HubCommand::Name, &HubCmd, HubCommand::Description}, {InfoCommand::Name, &InfoCmd, InfoCommand::Description}, {JobCommand::Name, &JobCmd, JobCommand::Description}, @@ -793,6 +799,9 @@ main(int argc, char** argv) Options.add_options()("d, debug", "Enable debugging", cxxopts::value(GlobalOptions.IsDebug)); Options.add_options()("v, verbose", "Enable verbose logging", cxxopts::value(GlobalOptions.IsVerbose)); + Options.add_options()("enable-execution-history", + "Record this invocation in the per-user execution history (use --enable-execution-history=false to suppress)", + cxxopts::value(GlobalOptions.EnableExecutionHistory)->default_value("true")->implicit_value("true")); Options.add_options()("malloc", "Configure memory allocator subsystem", cxxopts::value(MemoryOptions)->default_value("mimalloc")); Options.add_options()("help", "Show command line help"); Options.add_options()("c, command", "Sub command", cxxopts::value(SubCommand)); @@ -939,6 +948,11 @@ main(int argc, char** argv) SentryConfig.DatabasePath = SentryDatabasePath; + if (std::filesystem::path HistoryPath = GetInvocationHistoryPath(); !HistoryPath.empty()) + { + SentryConfig.AttachmentPaths.push_back(std::move(HistoryPath)); + } + Sentry.Initialize(SentryConfig, SB.ToString()); SentryIntegration::ClearCaches(); -- cgit v1.2.3 From 2dfb5da16b97a6c12e01977af5b5188522178a4e Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Mon, 20 Apr 2026 21:50:41 +0200 Subject: zen trace analysis support (#945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the **tourist** trace analysis library and builds a full `zen trace` command suite for working with Unreal Engine `.utrace` files. ### Trace analysis library (`thirdparty/tourist/`) - Adds the tourist library as a third-party dependency with three modules: **foundation** (platform primitives, memory, scheduling), **trace** (UE Trace protocol decoding), and **analysis** (event dispatching and analyzer framework). - Cross-platform support for Windows, Linux, and macOS. ### `zen trace` CLI commands (`src/zen/cmds/`, `src/zen/trace/`) - **`zen trace analyze`** — Summarize a `.utrace` file: session metadata, thread inventory, command line + build configuration, CPU profiling scopes, timing, event rates, log messages, and (with symbols) memory allocation metrics including live-allocs dumps, callstack-keyed aggregation, and allocation churn. Optional HTML output for memory reports. - **`zen trace inspect`** — Dump the event schema (declared types, fields, sizes) from a trace file. - **`zen trace trim`** — Extract a time-window from a trace into a new `.utrace` file. - **`zen trace serve`** — Launch a local HTTP server hosting an interactive trace viewer; opens in the default browser. ### Symbolication (`src/zen/trace/symbol_resolver.*`, `thirdparty/raw_pdb/`) - Pluggable resolver with multiple backends: `pdb` (in-tree raw_pdb), `dbghelp` (Windows), `llvm-symbolizer` (all platforms), `atos` (macOS). An `auto` backend picks the best available tool per platform. - Microsoft Symbol Server support: downloads PDBs on demand using a redirect-aware HTTP client. - Local PDB cache keyed by image GUID preserves symbols across binary recompilation. - Callstack trimming heuristic strips UE internal noise from reports. - Binary analysis cache (`.ucache_z`) avoids re-resolving the same trace. ### Interactive trace viewer (`src/zen/frontend/html/`, `src/zen/trace/trace_viewer_service.*`) - Timeline: scope-level detail, horizontal zoom/pan, vertical scrolling, viewport-driven loading with pre-computed LOD for responsive navigation of large traces. - Thread grouping (collapsible sidebar sections) synthesized from name suffixes, natural sort order, visual distinction between lane threads and OS threads. - Bookmark and region annotations; region categories with per-category toggles; bookmark marker toggle in the toolbar. - Filterable Logs tab showing captured `UE_LOG` output. - Stats tab with per-scope aggregate statistics. - Memory tab with interactive allocation analysis and an allocation size histogram. - CsvProfiler event parsing and chart UI. ### Other in-branch supporting changes - **Cross-platform browser launcher** (`browser_launcher.{h,cpp}`) used by `trace serve`. - **`ReciprocalU64`** fast 64-bit integer division (zencore/intmath) for trace analyzers. - **`parallelsort`** cross-platform parallel sort helper (zenutil). - Frontend zip build rule so the viewer's HTML assets are bundled into `zen.exe`. - `/Zo` flag for better optimized debug info on Windows release builds. - `trace-tests.cpp` in the `zen-test` harness (harness itself landed on main via #985). --- src/zen/zen.cpp | 68 ++++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 45 insertions(+), 23 deletions(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 984e8589b..02695419e 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -21,13 +21,13 @@ #include "cmds/service_cmd.h" #include "cmds/status_cmd.h" #include "cmds/top_cmd.h" -#include "cmds/trace_cmd.h" #include "cmds/ui_cmd.h" #include "cmds/up_cmd.h" #include "cmds/version_cmd.h" #include "cmds/vfs_cmd.h" #include "cmds/wipe_cmd.h" #include "cmds/workspaces_cmd.h" +#include "trace/trace_cmd.h" #include #include @@ -270,20 +270,34 @@ ZenCmdWithSubCommands::PrintHelp() Options().set_width(TuiConsoleColumns(80)); printf("%s\n", Options().help(Groups).c_str()); - // Append subcommand listing. + // Append subcommand listing. When a subcommand has aliases, display them as + // "name|alias1|alias2" in the left column so callers discover the alternate spellings. + auto FormatSubCmdName = [](ZenSubCmdBase& SubCmd) { + std::string Name(SubCmd.SubOptions().program()); + for (const std::string& Alias : SubCmd.Aliases()) + { + Name.push_back('|'); + Name.append(Alias); + } + return Name; + }; + + std::vector FormattedNames; + FormattedNames.reserve(m_SubCommands.size()); size_t MaxNameLen = 0; for (ZenSubCmdBase* SubCmd : m_SubCommands) { - MaxNameLen = std::max(MaxNameLen, SubCmd->SubOptions().program().size()); + FormattedNames.push_back(FormatSubCmdName(*SubCmd)); + MaxNameLen = std::max(MaxNameLen, FormattedNames.back().size()); } printf("subcommands:\n"); - for (ZenSubCmdBase* SubCmd : m_SubCommands) + for (size_t i = 0; i < m_SubCommands.size(); ++i) { printf(" %-*s %s\n", static_cast(MaxNameLen), - SubCmd->SubOptions().program().c_str(), - std::string(SubCmd->Description()).c_str()); + FormattedNames[i].c_str(), + std::string(m_SubCommands[i]->Description()).c_str()); } printf("\nFor global options run: zen --help\n"); } @@ -308,16 +322,33 @@ ZenCmdWithSubCommands::PrintSubCommandHelp(cxxopts::Options& SubCmdOptions) void ZenCmdWithSubCommands::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) { - std::vector SubOptionPtrs; - SubOptionPtrs.reserve(m_SubCommands.size()); - for (ZenSubCmdBase* SubCmd : m_SubCommands) - { - SubOptionPtrs.push_back(&SubCmd->SubOptions()); - } - + ZenSubCmdBase* MatchedSubCmd = nullptr; cxxopts::Options* MatchedSubOption = nullptr; std::vector SubCommandArguments; - int ParentArgc = GetSubCommand(Options(), argc, argv, SubOptionPtrs, MatchedSubOption, SubCommandArguments); + int ParentArgc = argc; + for (int i = 1; i < argc; ++i) + { + std::string_view Arg(argv[i]); + for (ZenSubCmdBase* SubCmd : m_SubCommands) + { + const bool NameMatch = SubCmd->SubOptions().program() == Arg; + const bool AliasMatch = + !NameMatch && std::find(SubCmd->Aliases().begin(), SubCmd->Aliases().end(), Arg) != SubCmd->Aliases().end(); + if (NameMatch || AliasMatch) + { + MatchedSubCmd = SubCmd; + MatchedSubOption = &SubCmd->SubOptions(); + break; + } + } + if (MatchedSubCmd != nullptr) + { + SubCommandArguments.push_back(argv[0]); + std::copy(&argv[i + 1], &argv[argc], std::back_inserter(SubCommandArguments)); + ParentArgc = i + 1; + break; + } + } // Intercept --help/-h in the parent arg range before calling ParseOptions so // we can append subcommand information to the output. When a subcommand was @@ -344,15 +375,6 @@ ZenCmdWithSubCommands::Run(const ZenCliOptions& GlobalOptions, int argc, char** throw OptionParseException("No subcommand specified", {}); } - ZenSubCmdBase* MatchedSubCmd = nullptr; - for (ZenSubCmdBase* SubCmd : m_SubCommands) - { - if (&SubCmd->SubOptions() == MatchedSubOption) - { - MatchedSubCmd = SubCmd; - break; - } - } ZEN_ASSERT(MatchedSubCmd != nullptr); // Intercept --help/-h in the subcommand args so we can show combined help -- cgit v1.2.3 From 6aa4efa21a09990998a4054e805e595ef38ae785 Mon Sep 17 00:00:00 2001 From: Dan Engelbrecht Date: Mon, 20 Apr 2026 22:13:10 +0200 Subject: hide secrets from log and sentry (#989) * scrub sensitive command line options from log and sentry --- src/zen/zen.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 02695419e..9e39d2d67 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -975,7 +975,9 @@ main(int argc, char** argv) SentryConfig.AttachmentPaths.push_back(std::move(HistoryPath)); } - Sentry.Initialize(SentryConfig, SB.ToString()); + std::string ScrubbedCmdLine = SB.ToString(); + ScrubSensitiveValues(ScrubbedCmdLine); + Sentry.Initialize(SentryConfig, ScrubbedCmdLine); SentryIntegration::ClearCaches(); } -- cgit v1.2.3 From 245d2e562165d048e5ee2ab97f1260975a8142d3 Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Tue, 21 Apr 2026 13:48:41 +0200 Subject: zen CLI security review fixes (#974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security review follow-ups to the `zen` CLI. Each fix stands on its own commit. Grouped by category below. ## Credentials and secrets - **Per-install random auth encryption key instead of a hardcoded literal.** The default AES key and IV used to encrypt persisted OIDC refresh tokens / OAuth client secrets were ASCII literals compiled into the public source. Replaced with 32+16 random bytes persisted to `/auth/machinekey.dat`. `SecureRandomBytes` added in zencore/crypto wrapping BCryptGenRandom / OpenSSL / mbedTLS CTR_DRBG. Partial override (only one of `--encryption-aes-key`/`--encryption-aes-iv`) is now rejected instead of silently using the hardcoded half. - **Wrap the machine key with OS-protected storage.** `machinekey.dat` is now a tagged format (4-byte magic + flags + wrapped-or-raw payload). Windows wraps via DPAPI (`CryptProtectData` at per-user scope) so a stolen disk copy cannot decrypt without the OS master key. macOS uses Keychain Services (GenericPassword under `org.unrealengine.zen.auth`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Linux uses libsecret (opt-in via `--zenlibsecret=yes`, off by default because headless servers typically have no Secret Service daemon). All platforms fall back to raw persistence with `0600` perms on POSIX when wrapping is unavailable. Legacy files from the prior commit are detected by size and still read. > Note: argv-redaction before Sentry on crash was previously part of this PR but was superseded by `ScrubSensitiveValues()` from #989; this PR now just calls that helper instead of walking argv itself. ## Path traversal - **Reject unsafe filenames from the remote oplog in `oplog-mirror`.** The filename from each oplog entry was joined to the mirror root without normalisation; a compromised remote could use drive letters, UNC shares, device path prefixes, absolute paths, or `..` components to write anywhere the zen user could write. An `UnsafeFileNameReason` check runs immediately after extraction, logs the offending filename, and aborts the mirror. - **Use the resolved absolute download-spec path in `builds download`.** `--download-spec-path` was computed into a sanitised absolute path, then the original unresolved path was passed to `ParseBuildManifest`, bypassing the `MakeSafeAbsolutePath` mitigations and reading from the process cwd rather than `--local-path`. ## Input validation - **Stop asserting on malformed `--build-id` / `--build-part-id`.** `Oid::FromHexString` asserts on bad input and `ZEN_ASSERT` is active in release, so a too-short or non-hex user value aborted the process instead of surfacing an `OptionParseException`. Routed all callers through `TryFromHexString`. Also fixes `ParseBuildPartId` reporting errors under the wrong option name. - **Check the JSON parse error in `oplog-export --builds-metadata-path`.** The single-arg `LoadCompactBinaryFromJson` overload discarded the parser error; malformed JSON shipped a truncated compact-binary `metadata` field to the server with no indication. Switched to the two-arg overload and throws a descriptive error naming the file and reason. - **Format the actual value in the malformed `--url` error.** The message was constructed with a literal `{}` placeholder and no `fmt::format` call, so users saw the placeholder instead of the offending URL. - **Require `--output-path` in `cache get` unless `--as-text` is set.** Previously an empty path auto-filled from the value key / attachment hash and wrote into the process cwd; the `--as-text && empty path` stdout branch was unreachable because the auto-fill ran first. - **Clear the cxxopts `allow_unrecognised_options` flag after permissive parse.** `ParseOptionsPermissive` set the flag on the Options it received and never cleared it, priming that Options for silent typo acceptance on any later reuse. Added `disallow_unrecognised_options()` to the vendored cxxopts (local patch — flagged at the declaration) and wrapped the toggle in RAII. ## Resource lifecycle - **Restore signal handlers via RAII.** `wipe`, `builds`, and `oplog-mirror` installed SIGINT/SIGBREAK handlers with raw `signal()` and never restored them; an option-parse throw left the handler targeting an abort flag nothing reads. Added `zen::ScopedSignalHandler` in zen.h and applied at all three sites (builds uses `std::optional` members so the guards survive past `OnParentOptionsParsed` into the subcommand's Run). - **Route SIGINT in `oplog-mirror` to the worker-pool abort flag.** The command declared a local `std::atomic AbortFlag` but no handler targeted it — Ctrl-C killed the process instead of cleanly aborting. Added a `MirrorAbortFlag` / `MirrorSignalCallbackHandler` pair in projectstore_impl and bound the local as a reference; existing `.store`/`.load`/capture sites unchanged. - **Clean up the `cache get` temp download on every exit path.** `Http.Download` parks the payload in the system temp dir; a failed `MoveToFile` (cross-volume, denied target) or an exception could leave the temp file behind. The downloaded buffer is already flagged delete-on-close by `HttpClient`, so the fix is just to clear that flag after a successful `MoveToFile` so the renamed-out file isn't reaped. ## Other - **Fix wrong URL fields in `oplog-export` / `oplog-import` builds-branch descriptions.** Two operator-facing "[builds] URL/namespace/bucket/buildsid" messages formatted `m_CloudUrl` instead of `m_BuildsUrl` / `m_BuildsHost` (copy-paste from neighbouring `[cloud]` branches), shown as empty or stale at the start of an export/import. - **Fix "Can't find oplog in project '{}'" formatting and a "Failed top mirror" typo in projectstore_cmd.** - **Fix a misleading `oplog-export` comment on the `--zen` scheme default** ("Assume https" vs. the `http://` the code writes). - **Fail `ScrambleDir` when `RemoveFile` doesn't delete.** The `zen builds test` scramble phase used `(void)RemoveFile(FilePath)`, discarding both the bool return and the error. A quiet delete failure let verification run against stale state; switched to the two-arg overload and throw on false return or non-empty `error_code`. --- src/zen/zen.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 9e39d2d67..048cbe920 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -42,6 +42,7 @@ #include #include #include +#include #include #include #include @@ -188,6 +189,9 @@ ZenCmdBase::ParseOptionsPermissive(cxxopts::Options& CmdOptions, int argc, char* { CmdOptions.set_width(TuiConsoleColumns(80)); CmdOptions.allow_unrecognised_options(); + // Revert the flag on scope exit so re-parsing the same Options later is strict. + // cxxopts has no getter for the previous state, so we unconditionally clear it. + auto _ = MakeGuard([&]() { CmdOptions.disallow_unrecognised_options(); }); cxxopts::ParseResult Result; @@ -964,8 +968,7 @@ main(int argc, char** argv) { SB.Append(' '); } - - SB.Append(argv[i]); + SB.Append(std::string_view(argv[i])); } SentryConfig.DatabasePath = SentryDatabasePath; -- cgit v1.2.3 From 213e53c4d603c51037f43c86b16fd90d2ac48c4a Mon Sep 17 00:00:00 2001 From: Stefan Boberg Date: Wed, 22 Apr 2026 10:36:24 +0200 Subject: zen CLI: suggest similar commands on typos (#1000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface "did you mean?" suggestions when the `zen` CLI is invoked with an unknown command or subcommand, so users don't have to dig through `zen --help` every time they mistype. ``` $ zen stauts Unknown command specified: 'stauts' The most similar commands are: status Run 'zen --help' for the full list of commands. ``` ``` $ zen cache statz Unknown subcommand: 'statz' The most similar subcommands are: stats ``` ## Algorithm - Damerau-Levenshtein edit distance with case-insensitive ASCII comparison — handles insertions, deletions, substitutions, and adjacent transpositions (e.g. `versoin` → `version`). - Small prefix-match bonus so short inputs like `ca` still surface longer commands like `cache` without having to relax the distance threshold to the point where it admits noise. - Distance threshold scales with input length (`clamp(len/2, 1, 3)`). Very short inputs rely on the prefix bonus; longer inputs tolerate up to three edits. - Top 5 results by distance, stable-sorted. - Hidden commands (deprecated shims like `cache-stats`) are excluded from the candidate set so we don't advertise them. --- src/zen/zen.cpp | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) (limited to 'src/zen/zen.cpp') diff --git a/src/zen/zen.cpp b/src/zen/zen.cpp index 048cbe920..8f3a5d5bc 100644 --- a/src/zen/zen.cpp +++ b/src/zen/zen.cpp @@ -47,6 +47,7 @@ #include #include #include +#include #include #include @@ -370,6 +371,46 @@ ZenCmdWithSubCommands::Run(const ZenCliOptions& GlobalOptions, int argc, char** if (MatchedSubOption == nullptr) { + // If the user typed what looks like a subcommand name (a non-option arg) but nothing + // matched, surface "did you mean" suggestions before falling through to full help. + std::string_view UnknownAttempt; + for (int i = 1; i < argc; ++i) + { + std::string_view Arg(argv[i]); + if (!Arg.empty() && Arg[0] != '-') + { + UnknownAttempt = Arg; + break; + } + } + + if (!UnknownAttempt.empty() && !m_SubCommands.empty()) + { + std::vector SubNames; + SubNames.reserve(m_SubCommands.size() * 2); + for (ZenSubCmdBase* SubCmd : m_SubCommands) + { + SubNames.emplace_back(SubCmd->SubOptions().program()); + for (const std::string& Alias : SubCmd->Aliases()) + { + SubNames.emplace_back(Alias); + } + } + std::vector Suggestions = SuggestSimilarCommands(UnknownAttempt, SubNames); + if (!Suggestions.empty()) + { + printf("Unknown subcommand: '%.*s'\n\n", static_cast(UnknownAttempt.size()), UnknownAttempt.data()); + printf("The most similar subcommands are:\n"); + for (std::string_view Name : Suggestions) + { + printf(" %.*s\n", static_cast(Name.size()), Name.data()); + } + printf("\n"); + fflush(stdout); + throw OptionParseException("Unknown subcommand", {}); + } + } + if (!ParseOptions(Options(), ParentArgc, argv)) { return; @@ -1078,7 +1119,28 @@ main(int argc, char** argv) } } - printf("Unknown command specified: '%s', exiting\n", SubCommand.c_str()); + printf("Unknown command specified: '%s'\n", SubCommand.c_str()); + + std::vector VisibleNames; + VisibleNames.reserve(std::size(Commands)); + for (const CommandInfo& CmdInfo : Commands) + { + if (!CmdInfo.Cmd->IsHidden()) + { + VisibleNames.emplace_back(CmdInfo.CmdName); + } + } + + std::vector Suggestions = zen::SuggestSimilarCommands(SubCommand, VisibleNames); + if (!Suggestions.empty()) + { + printf("\nThe most similar commands are:\n"); + for (std::string_view Name : Suggestions) + { + printf(" %.*s\n", static_cast(Name.size()), Name.data()); + } + } + printf("\nRun 'zen --help' for the full list of commands.\n"); return (int)ReturnCode::kBadInput; } catch (const OptionParseException& Ex) -- cgit v1.2.3