Commit Graph
14 Commits
Author SHA1 Message Date
Neil 3074852b7c fix(orcad): seal integration credentials on the headless server instead of storing them in plaintext (#29412)
* fix(orcad): seal integration credentials on the headless server instead of storing them in plaintext

The Node SecretStore always reported encryption unavailable, so every
credential writer on orcad fell back to plaintext. orcad now seals with
AES-256-GCM using a host-owned master key kept in the OS keyring (macOS
Keychain, Linux Secret Service) when reachable at first start, else in a
0600 profile file bound to the machine ID. A key that cannot load fails
closed instead of downgrading. Existing plaintext credentials are sealed
on startup without loss.

* docs: describe how the headless server seals credentials and its limits

* fix(orcad): unique credential staging files, no keyring items for temp profiles, isolate startup test

* fix(orcad): keep the credential key with the credentials, one record per machine

* fix(orcad): never re-key over an unreadable record, serialize first-start key creation, refuse an unrestricted key file

* fix(orcad): verify a key record's Windows ACL and wait out a concurrent first start's keyring write

* fix(orcad): keep a keyring claim and fail closed when its store result is uncertain

* fix(credentials): restrict the staged file's Windows ACL before it replaces a credential

* fix(orcad): a temporary profile never creates a key in, or re-seals, the real credential home

* ci: baseline the two single-file credential ACL restrictions

* fix(credentials): stage Windows credentials born restricted and verify the key record asynchronously; document rollback

* test(bitbucket): inject the write failure into the Windows staging writer too
2026-10-15 23:18:05 -07:00
OrcaWinandNeil 16eaceb6a3 fix(orcad): orca serve owns its Claude and Codex accounts again (#29209)
* fix(orcad): orca serve owns its Claude and Codex accounts again (#28962)

orcad never wired account services, so every accounts.* RPC failed once
orca serve moved onto orcad. Build the same account services the desktop
builds (now in provider-account-services.ts), made Electron-free, and
install them in orcad except on a WSL host, whose desktop owns its accounts.

* test(orcad): keep account-services test off the real userData when XDG_CONFIG_HOME is set

* fix(orcad): structured Codex chats and session discovery follow this host's managed accounts

Create orcad's account services before the runtime so its structured Codex launch/read resolvers and catalog-probe prep are constructor deps, as on the desktop. A client-sent account home still wins, and a WSL host keeps them unset so the desktop-pushed CODEX_HOME applies. AI Vault session discovery now includes this host's managed Codex homes.

* fix(orcad): automatic Codex resumes follow the account home their session was recorded in

Share the desktop's resume launch prep as a factory and wire it into orcad's PTY registration and AI Vault resume, so a pane restored after an account switch resumes from its origin home. A client-sent account home keeps resuming from itself.

---------

Co-authored-by: Neil <neil@stably.ai>
2026-10-15 17:38:52 -07:00
OrcaWinandOrca Worker 4559c250b3 fix(windows): restrict secure files in-process instead of spawning icacls (#28135)
* fix(windows): restrict secure files in-process instead of spawning icacls

Every secure write on Windows spawned icacls.exe five times on the calling
thread (the Electron main thread for most stores): verify, /reset,
/inheritance:r /grant:r, verify, verify. Process creation under endpoint
security costs ~0.6 s each, so each write froze the UI for seconds (#26027).
The /reset + /inheritance:r pair is also what behavioural EDR scores as
ransomware tradecraft (#25583).

Add native/windows-security (@orca/windows-security): a small N-API addon
over advapi32 that reads and writes DACLs in-process. It exposes the token
user SID, a DACL read as SDDL, a DACL write (sync and on the thread pool),
a create-with-DACL file write, and an additive grant. Policy and
verification stay in TypeScript.

- writeSecureFile creates each temp file already carrying the protected
  user/SYSTEM/Administrators DACL (CreateFileW + SECURITY_ATTRIBUTES), reads
  it back once, and renames it. No ACL is modified on the write path and no
  process starts. Before: 5-7 spawns, 94-138 ms per write. After: 0 spawns,
  ~0.5 ms.
- A secure directory is verified once per process. It is rewritten only
  when verification fails, on the thread pool, because the write walks
  every descendant (~2 s on a 29k-entry userData).
- The verifier follows OpenSSH's rule: only the user, SYSTEM and
  Administrators may be granted anything, and the path must be protected
  (or, for a file, inherit from a secure parent). DACLs written by the
  icacls-era hardening pass unchanged, so there is no migration.
- If the addon cannot load, the write still happens and reports
  unrestricted, logged once. There is no icacls or PowerShell fallback.
- grantDirAcl, the startup userData grant, and the install-dir probe and
  package repair use the same addon, so whoami.exe and icacls.exe are no
  longer spawned anywhere at runtime.
- orcad's Windows slots ship the addon beside their bundles (built per
  arch, PE-machine checked). Its data-root privacy check fails closed, so
  without the addon a Windows orcad would not start.

Fixes #26027
Fixes #25583

* refactor(secure-file): harden existing Windows files through the sync lane

The read path called restrictWindowsPathSync directly and recorded success
itself; the existing sync lane already does both. One call site fewer.

* refactor(windows-security): one load, one rule, no fallthrough (review #28135)

- The descriptor parser is anchored, so a conditional ACE (its body holds
  parentheses) or anything else it cannot read fails closed instead of
  verifying the ACEs before it.
- findSecureDaclViolation is one flat rule: protected, allow rules only for
  the user, SYSTEM and Administrators (OpenSSH's ACE rule), and full
  control for the user. The inherits-from-a-secure-parent exception is gone:
  it accepted exactly what loosening the parent reaches.
- The addon and the user SID load once, behind one wrapper that adds the
  extended-length prefix to every path and runs async DACL writes to one
  path in order, so a startup grant cannot land after a re-lock and restore
  the broader DACL.
- With the addon loaded, a failed born-restricted create throws with the
  matching Node error code instead of falling back to a plain write.
- A grant leaves a NULL DACL untouched instead of turning 'everyone' into
  'only this SID'. WriteFile runs once and fails on a short write.
- The artifact create-intent store writes through writeSecureFile and drops
  its own temp writer, directory LRU and synchronous directory re-lock.
- Test seams the global fake makes redundant are gone.

* fix(packaging): fail a Windows build whose workspace addon binary is missing or foreign-arch

The @orca/windows-* workspace links exist on every host, so their presence
proved nothing: a missing or wrong-architecture .node only surfaced at
runtime, as unrestricted secure files or an orcad that refuses to start.
beforePack now reads each addon's PE machine against the target arch.

* refactor(windows-security): prune what the native ACL left dead (review #28135)

- Sync-spawn ratchet: hardenSecurePath and the sync-flag exemption it alone
  used are gone, and the advice and baseline header no longer say secure
  writers run icacls.
- The install-dir probe reads ACEs through parseSddlDacl, so an ACE it
  cannot parse fails the whole reading closed instead of being skipped; AC
  is a resolved alias there now.
- The packaging check reads the addon binary through node_modules, the copy
  that ships.
- No winCode-to-errno table: nothing reads .code off a failed secure write.
- The load memo is not mutated; secure-path keeps its own log-once flag.
- grantInstallDirPackageAcl is a plain function, and secure-file restricts
  a Windows directory and a POSIX path directly instead of through one
  two-platform helper.

* fix(windows-security): resolve LA in real-ACL tests; tear a file whose close fails

- The GitHub Windows runner is the built-in Administrator (RID 500), whose
  SID reads back as LA. Production already resolves it through
  localDomainSidOf; the win32 test helpers parsed without it. One shared
  readDaclOf resolves aliases for every real-ACL suite, and the verifier's
  unit test covers an LA-aliased directory and LA as a stranger.
- createFileWithDacl treats a failed CloseHandle like a failed write (lost
  data on a network volume) and deletes the torn file.
- Drop a duplicate test reset and a typo.

* fix(windows-security): load only from inside the app; read DACLs as structure (review #28135)

- Loader: orcad's staged binary first, then the workspace package only if
  its real path is inside the app (process.resourcesPath, or this
  checkout). A bare specifier otherwise walks every parent up to
  C:\node_modules, which any local user may create, and orcad holds
  credentials.
- The addon reads a DACL as { protected, aces: [{ type, flags, mask, sid }] }
  via GetAce, with a SID only for plain allow/deny ACEs and null for a NULL
  DACL. findSecureDaclViolation and the install-dir probe check that
  structure, so conditional, callback and object ACEs fail closed. The SDDL
  parser, its alias table, LA/localDomainSid resolution and getDaclSddl are
  gone; SDDL is only the write format.
- Install-dir tree pass: files flagless as before, but a directory only when
  its own DACL is protected (inheritably). Re-granting inheriting
  directories re-propagated each subtree (3.2 s for 1,093 directories). The
  root is no longer granted, or counted, twice. The two passes are plain
  functions behind a { root, tree } seam.
- One namespacing (path.win32.toNamespacedPath) at the addon boundary, also
  the per-path ordering key; the comment states the real sync-grant
  invariant.
- restrictWindowsDirectory(dir): Promise<boolean> replaces the callback
  lane; the reporter is guarded once, so the 'settle' stage is gone.
- Sync-spawn ratchet: the secure writers no longer spawn or block, so they
  leave the gate (109 -> 61 uses).
- Errors name the Win32 call and code only; a failed
  GetSecurityDescriptorControl fails the write instead of defaulting to
  unprotected.

* test(secure-file): assert the durable POSIX write's outcome, not the old chmod order

writeSecureFile now chmods the temp file to 0o600 before fsyncing it, so
the fsync also covers the mode change and the file is owner-writable when
opened. Assert what matters under umask 0o200: the write succeeds, the
temp file is fsynced read-only, and the published file is exactly 0o600.

* fix(windows-security): namespace paths with the host's toNamespacedPath

path.win32.toNamespacedPath rewrote POSIX temp paths (/tmp/x -> \tmp\x) in the
Linux runs of the Windows-logic unit tests, so the fake addon wrote to the wrong
place. The addon only runs on Windows, where the host function is the win32 one;
elsewhere it is the identity. The long-path assertion runs on Windows only.

* test(windows-security): assert the install-dir long-path prefix on Windows only

The prefix comes from the host's toNamespacedPath, which is the identity off
Windows; the Linux run of this suite expected the win32 escape.

* fix(windows-user-data-acl): grant the root even when its children cannot be listed (review #28135)

A readdir failure on userData aborted the whole grant, so the root grant
that could repair a DACL refusing the listing was never attempted. The
listing failure is now recorded, the root is still granted, and the
marker stays withheld so the next launch retries.

* test(windows-security): wait for relock propagation, accept Entra ID SIDs (review #28135)

- The large-relock test waited only for the parent to turn protected,
  which happens before propagation reaches the children, then read a child.
  It now waits on the parent and both ends of the child range.
- The token-user test rejected S-1-12-1 (Entra ID) accounts, failing the
  suite on Entra-joined dev machines.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-13 01:56:55 -07:00
OrcaWinandOrca Worker 889ddcc6bd fix(agent-hooks): register forward-slash Windows hook paths for Grok, Devin, ZCode and Antigravity (#27713)
* fix(agent-hooks): register forward-slash Windows hook paths for Grok, Devin, ZCode and Antigravity

These four agents registered their native-Windows status hook as a bare
backslash path. A Git Bash hook host strips the backslashes, the command exits
127, and no status event reaches Orca. Use the same direct forward-slash form
Claude, Codex and Muse already register, keeping the cmd-safe gate and the
encoded PowerShell fallback. Existing backslash entries still match the
managed-command matcher, so the next install replaces them and keeps user hooks.

* perf(hooks): register direct .cmd hooks for Gemini, DSH, Kiro, Droid and Command Code on Windows

On a cmd-safe profile path these agents now register the managed .cmd path
itself instead of a `powershell.exe -EncodedCommand` launcher, so each hook
event no longer pays a second PowerShell start-up on top of the runner's own
shell. Profiles the bare path cannot carry (spaces, non-ASCII, UNC) keep the
encoded launcher. Same pattern as Claude (#18875) and Muse (#26755).

Refs #20913

* test(agent-hooks): expect forward-slash Antigravity hooks after the Unicode upgrade

ASCII profiles now register the direct forward-slash wrapper path, so the
upgrade-delivery test expects that form and also runs it through Git Bash.
Unicode profiles keep the encoded launcher.

* test(agent-hooks): assert the registered hook lives under the mocked home

Pins that the os mock reaches the agents' node:os imports, so the host test
never installs into the real profile.

* refactor(hooks): one Windows batch-hook helper and one payload-delivery table

- Add wrapWindowsBatchHookCommand (bare .cmd path, encoded launcher
  otherwise) and use it for Muse, Gemini, DSH, Kiro, Droid and Command Code.
- Fold the five agents into windows-hook-payload-delivery.test.ts as rows
  run under each vendor's shell, from a plain and a spaced profile, with a
  stale encoded registration seeded per row; drop the separate runner suite
  and its CI list entries.
- Replace the Windows-only per-agent registration tests with ones that stub
  process.platform, so they run on every CI OS.
- Fix stale launcher, helper and DSH comments.

Refs #20913

* refactor(agent-hooks): register Grok, Devin, ZCode and Antigravity through wrapWindowsBatchHookCommand

Use the shared batch-hook helper from #27717 instead of an inline direct-or-cmd
fallback. A UNC home is cmd-safe but has no drive letter; the old fallback
registered it as a bare backslash path that Git Bash cannot start, while the
helper sends it to the encoded launcher.

wrapWindowsCmdHookCommand has no production callers left, so it is removed and
its tests point at the helper. Grok, Devin and ZCode join the Windows payload
delivery table with a legacy backslash entry to sweep, replacing the separate
host test, and the Antigravity migration test now runs on every platform.

---------

Co-authored-by: Orca Worker <orca-worker@localhost>
2026-10-12 14:31:04 -07:00
OrcaWinandm4air 398cb846ff fix(serve): report secrets unavailable instead of hanging on an unreachable macOS keychain (#27441)
* fix(serve): report secrets unavailable instead of hanging on an unreachable macOS keychain

* fix(serve): classify keychain probe by exit code, skip under a mock keychain, ratchet direct safeStorage imports

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-11 03:20:15 -07:00
Neil 6de900fcc6 test(zcode): verify rollback with real Windows file permissions 2026-10-10 16:24:14 -07:00
Neil 1f6ab26ec6 fix(accounts): preserve competing keys during failed GLM saves 2026-10-10 16:24:14 -07:00
Luchong ae573a9063 fix(accounts): keep an identical plaintext key after a failed restore
A restored plaintext envelope can match the rejected replacement byte for
byte. Do not delete that file, since it is the previous key rather than a
new secret.
2026-10-10 16:24:14 -07:00
Luchong c33fa877d4 fix(accounts): remove a rejected GLM key when restore fails
A failed plaintext replacement can publish the new key before file
restriction is confirmed. If restoring the previous key fails before it
replaces that file, delete only the rejected envelope so a completed
restore is left intact.
2026-10-10 16:24:14 -07:00
87da8432cd fix(zcode): isolate read-only account discovery and quota ownership
Co-authored-by: Luchong <lu740528977@gmail.com>
Co-authored-by: GuanBear <guanbear@users.noreply.github.com>
2026-10-10 16:04:08 -07:00
Neil 1912c90141 Extract subprocess handling into @orca/process-host (#26963)
Extract subprocess execution and process contracts into @orca/process-host with explicit public entry points, independent checks, and one Node 18 compatible CommonJS runtime. Migrate application consumers and integrate package builds, dependency selection, import enforcement, and runtime resource staging.

Preserve daemon startup ownership and profile isolation, and qualify development rebuild recovery and concurrent output publication. Remove unnecessary aliases, test forwarding exports, and a duplicate output scan.

Validation: full typechecks, changed-code quality, package and artifact checks, native Windows builds/types plus 306 runtime tests, and independent native reviews.

Matched full E2E controls on original main and unchanged reviewed head both pass all 17 applicable tests (nine skips). Earlier intermittent E2E failures are documented in the PR; no assertions were weakened. The final combined-tree audit preserves both branches with no import-boundary violations.
2026-10-09 18:32:28 -07:00
NeilandLuchong b032867021 feat(accounts): link standalone GLM Coding Plans (#24618)
* feat(accounts): link standalone GLM Coding Plans

Adapt the reviewed GLM accounts contribution to current main, retain Antigravity behavior, guard late credential results, expose storage protection, and redact quota errors.

Co-authored-by: Luchong <lu740528977@gmail.com>

* fix(accounts): retain GLM credential results during quota refresh

* fix(accounts): make GLM credential editing desktop-only

* fix(accounts): mirror the host GLM site in paired clients

* fix(accounts): report unknown GLM host details and split web settings tests

Apply the independently reviewed Accounts correction from697284a without the v2 adapter commits. Preserve the saved-key store and serialized write behavior.

* fix(zcode): ship required GLM account translation entries

* chore: record GLM reconciliation hook validation

* chore: validate installed GLM commit hooks

* test: complete GLM account fixtures and web API inventory

---------

Co-authored-by: Luchong <lu740528977@gmail.com>
2026-10-02 20:57:47 -07:00
Neil 69839c253e feat(zcode): explain a ZCode build that has no terminal UI (#22730)
* feat(zcode): explain a ZCode build that has no terminal UI

ZCode ships one agent runtime behind two front ends. The desktop app bundles it
without `@zcode/tui`, because it draws its own window in Electron. Put that
bundle on PATH as `zcode` and it answers `--version`, runs `-p` headlessly, and
passes `zcode doctor` — so Orca detects it, launches it, and installs hooks
against it, all successfully. Only the interactive session fails, leaving a bare
Node stack trace in the pane that reads as a broken Orca integration.

Watch a freshly launched ZCode pane's first output and replace that with an
explanation: Orca's hooks are fine, this `zcode` just cannot open a session,
install one that ships the TUI.

The rule keys on Node's own module-resolution error rather than on the healthy
build's "TUI requires an interactive terminal." message, because ZCode localizes
the latter (`TUI 需要交互式终端。` in zh-CN) and matching it would miss every
non-English user. Node's error is not translated and names the package.

Scoped so it costs a healthy pane nothing: it runs only for a pane Orca launched
as `zcode`, and only over the first 8 KiB, because a module-resolution failure
happens before the runtime renders anything.

Evidence: `src/main/runtime/__fixtures__/zcode-missing-tui.txt`, a recorded PTY
capture of the desktop bundle refusing to start, per
docs/reference/agent-pty-transcript-capture.md.

Reported-by: JWu527

* refactor(zcode): ask the CLI if it can open a session instead of watching for the failure

The stream watcher this replaces never fired. Before/after screenshots were
identical and instrumentation showed the hook never ran, so the sidecar was
both misplaced and racing a failure that lands ~440ms after spawn.

Replace it with a direct question, answered once per run and cached.

Reading zai-org/ZCode shows why running it is the only way to ask, and why the
answer is unambiguous. `--version` and `doctor` are byte-identical in shape
between a build that has the terminal UI and one that does not, because the TUI
is only ever touched on the `tui` command path. There, `runTuiCommand` calls
`loadTuiRuntime()` before anything else, and `runTui` checks for a TTY only
after that module is already loaded. So with stdin at EOF:

  - no TUI  -> fails in the loader  -> Node's module-resolution error
  - has TUI -> loads, then declines -> "TUI requires an interactive terminal."

The module error is therefore present exactly when the terminal UI is absent.
All three shipping shapes land correctly: an npm/node-bundle install resolves
`@zcode/tui` as a real package (esbuild marks it external, so it is never
inlined), a SEA build always carries it as embedded assets, and the desktop
app's bundled runtime carries neither.

Verified against both real builds on this machine rather than a mock: the
desktop bundle answers `missing-tui`, a CLI built from source answers
`interactive`, and a command that does not exist answers `unknown` — the probe
fails open so an unrelated spawn failure never accuses a working CLI.

* feat(zcode): warn at launch when the installed zcode cannot open a session

Wires the capability probe to the one place a ZCode launch is first known:
terminal tab creation, which runs before the pane connects, so the explanation
reaches the screen alongside the failure rather than after it.

- main exposes the cached probe over `preflight:zcodeInteractiveCapability`,
  beside the other "what can the installed CLIs do" answers
- the web preload stub answers `unknown`, because a paired client has no
  business deciding anything about the host's CLI install
- the renderer notice is advisory: a probe that cannot run never blocks a launch

Verified in the running app against the real desktop bundle: creating a ZCode
workspace now shows "This ZCode build has no terminal UI" next to the stack
trace, where before the trace stood alone.
2026-09-25 02:28:40 -07:00
Neil 90801e2deb feat(agents): add first-class ZCode harness (#22464)
* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
2026-09-25 02:17:51 -07:00