chore(deps): track master for protocol, cpp-sdk and plugin-qt (#92)

* feat(access-policy): --access-policy enforce, and prove it on a real daemon

`--access-policy` already reached the runtime; what was missing was a way to
ask for deny-by-default without hand-writing JSON, and any evidence that it
works. The README actively said the opposite ("enforcement is not yet
implemented ... a no-op for now") — it has been enforced for a while.

resolveAccessPolicyArg moves out of main.cpp into daemon/access_policy_arg.
so it can be unit-tested, and gains one spelling: the literal `enforce`
expands to {"version":1,"mode":"enforce","restrictions":{}}. That is not a
second switch — `mode` is still the runtime's only switch — it is the bare
document that arms it. Checked before the file branch, so arming enforcement
can't depend on the daemon's working directory.

The integration tests are the point: same binaries, same modules, same call,
policy the only variable. test_ipc_module declares test_basic_module and
test_extlib_module; test_basic_module declares nothing.
  no flag  -> requestModule(test_basic_module, test_extlib_module) mints
  enforce  -> the same call is refused, and both names appear in the log
  enforce  -> requestModule(test_ipc_module, test_basic_module) still mints
The third is the one that matters; a change that refused everything would
pass the second on its own. The refusal is matched structurally rather than
by exact text because the two capability_module implementations in this tree
quote the names differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(qt-host): link the Qt host runtime from logos-plugin-qt, not logos-qt-sdk

logoscore's daemon and its in-process core service are built on LogosAPI,
LogosAPIProvider and LogosProviderObject. Those moved out of logos-qt-sdk
into logos-plugin-qt, which publishes them as the `logos-qt-host` package
with the CMake target logos-qt-host::logos_qt_host. Point at that target.

Those three headers were the ONLY thing this repo took from logos-qt-sdk —
it emits no Qt consumer wrappers, ships no UI plugin, and never touches
logos_qt_lp_bridge.h or logos_ui_plugin_context.h — so the logos-qt-sdk
input is dropped outright rather than kept alongside. LOGOS_QT_SDK_ROOT
becomes LOGOS_QT_HOST_ROOT in all three derivations (build, tests,
buildPortable), and `--version` now reports the logos-plugin-qt commit.

Both new failure modes are hard errors, never silent skips: an unset
LOGOS_QT_HOST_ROOT is a FATAL_ERROR before find_package runs, and a
find_package that somehow does not define the imported target is a
FATAL_ERROR too.

logos-qt-host needs TokenManager::forIdentity/isolateIdentity, which
logos-protocol only grew on its per-client-token-store commit, so the
lock moves there. logos-plugin-qt is rev-pinned for now because
nix/qt-host.nix does not exist on its default branch yet.

Verified on aarch64-darwin: `nix build .#checks.aarch64-darwin.tests`
passes 21/21 with the committed lock and no overrides (same 21 as the
pre-change baseline), .#cli and .#cli-bundle-dir build, and the set of
LogosAPI/LogosAPIProvider/LogosProviderObject/qtArgDecode symbols in the
logoscore binary is identical to the pre-change build.

* chore(deps): re-pin the SDK stack onto the pushed b4 revs

Rebased onto master, so the inputs have to name the revs the rest of the b4
stack was actually pushed at rather than each input's default branch:

  logos-cpp-sdk           a04b2788  b3 codegen tip; a strict descendant of
                                    cpp-sdk master, so forward-only
  logos-protocol          c8bab12   per-client token store — logos-qt-host
                                    calls TokenManager::forIdentity, which
                                    exists nowhere else
  logos-plugin-qt         cc24fa1   was 8ccb1fc. The superset branch that
                                    logos-liblogos and logos-module-builder
                                    also pin, so exactly ONE logos-qt-host
                                    is in the closure — this CLI links it
                                    directly AND through liblogos_core
  logos-liblogos          f2a15ef   the liblogos built on that same qt-host
  logos-capability-module 0cb33fb   master, pinned explicitly — see below

All five are rev-pinned in the URL rather than left to the lock: every one is
a branch commit, so an unpinned url lets `nix flake update` silently relock
onto a default branch that does not build here.

capability_module deliberately does NOT move to the universal port (07dba1f).
That port declares metadata.json#host_services and fails closed until a host
calls logos_module_grant_host_services — and nothing in this stack calls it
yet (neither logos-liblogos nor logos-plugin-qt contains a single call site).
Built against it, the daemon's capability gate refuses EVERY requestModule
with "not granted the token_registry host service", so no module can call
another; the new access-policy integration test caught exactly that. 0cb33fb
is what logos-liblogos and logos-standalone-app lock too.

Verified on aarch64-darwin with the committed lock and no overrides:
  .#checks.aarch64-darwin.tests-logosctl   191 + 25 + 21 tests, all PASSED
  .#checks.aarch64-darwin.tests-logoscore   20 + 24 tests, all PASSED
  .#checks.aarch64-darwin.tests             built (exit 0)
  .#packages.aarch64-darwin.{cli,ctl}       built (exit 0)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): rev-pin logos-test-modules at the b4 qt-host tip

The daemon-backed integration checks load these plugins into the daemon
this repo builds, so the two share one host runtime in one process image
-- the same constraint that already rev-pins logos-liblogos. a639b934
links the test modules against logos-qt-host rather than logos-qt-sdk and
carries the matching B4 stack pins; the previous lock sat on master
(f8077fab), which predates that repoint.

The URL had to change, not just the lock. The input was an UNPINNED url,
so it resolved to the default branch -- and f8077fab IS master's tip.
`nix flake update logos-test-modules` was therefore a silent no-op that
would leave the ten b4 commits behind while reporting success.

f8077fab is a strict ancestor of a639b934 (verified on a non-shallow
clone), so this is forward-only, not a lineage switch.

Two behaviour changes ride along and were checked against this repo's
assertions rather than assumed safe:
  * test_basic_module and test_extlib_module migrate to
    interface "universal". Neither declares metadata.json#host_services,
    so the fail-closed gate that keeps logos-capability-module pinned off
    its universal port does not apply here.
  * stringLength now answers in CHARACTERS, not bytes. Every assertion
    here is ASCII ("abcdef" -> 6), so the two agree.

The access-policy fixture still has its pair: test_ipc_module declares
[test_basic_module, test_extlib_module] and test_basic_module declares
none, so basic -> extlib stays undeclared.

Checks built by name, all exit 0: tests-logosctl, tests-logoscore,
tests. 281 tests, 0 failures, 0 skips.

* test: use test_ipc_new_api_module as the transitive-dependency fixture

These integration tests pick a module that DECLARES the other two, so one
load-module has to pull all three, and then request a token across that edge.
test_ipc_module was that fixture; it is being retired as a duplicate. Its
successor declares exactly the same dependency pair, so the fixture role
transfers unchanged.

Worth doing in the same breath as the retirement rather than after: these call
GTEST_SKIP() when the module is missing, so deleting the module out from under
them would not have turned anything red — the dependency-resolution and
token-request coverage would simply have stopped running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): refuse an unknown target instead of silently skipping it

`logos_use_shared_runtime_from_dll` empties the static archive of each named
IMPORTED target so the symbol resolves to liblogos_core.dll's exported copy
instead. It skipped any name that was not a target, which makes a typo or a
moved target silent — and the failure it hides is the duplicate-statics class:
the image keeps its own static copy of the shared runtime alongside the DLL's,
and PE has no interposition to collapse the two.

That hazard was already WRITTEN DOWN at basecamp's call site ("naming the old
target here would be a silent no-op … Windows would regress to the 29
'rejecting unauthorized call' lines this shim exists to prevent") — documented,
but not enforced. This enforces it.

Taken from feat/sdk-codegen-phase-a, which hardened its logoscore-cli copy and
never fixed basecamp's; feat/sdk-codegen-b3 has neither. It is the one place
where reconciling onto b3 would otherwise lose work, so both copies get it.

Behaviour is unchanged for every current caller: the function early-returns off
Windows, and both call sites pass the same two targets
(logos-qt-host::logos_qt_host, logos-protocol::logos_protocol) that phase-a's
hardened copy already accepts. x86_64-windows still evaluates (386 packages).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: use logos-co/setup-nix-cache-action for Nix setup and caching

Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop references to removed generator flags and interfaces

README and docs described module authoring in terms of LogosProviderBase,
LOGOS_METHOD and --provider-header, none of which exist. Updated to the
universal model, keeping the retired shapes named as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): track master for protocol, cpp-sdk and plugin-qt

logos-protocol#59, logos-cpp-sdk#138 and logos-plugin-qt#19 merged, so the three
rev pins bridging to them are retired, each with its rationale rewritten to name
the PR that closed the gap.

Left pinned: logos-liblogos, logos-capability-module and logos-test-modules —
their branches are still in flight and no merged upstream was confirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dario Lipicar
2026-08-20 12:47:31 -03:00
committed by GitHub
co-authored by Claude Opus 5
parent d787034e36
commit 0b2afed18a
20 changed files with 593254 additions and 27481 deletions
+8 -8
View File
@@ -8,6 +8,9 @@ on:
jobs:
test:
# ATTIC_TOKEN_PUBLIC only exists in the public-cache environment; master
# jobs must opt into it to publish to the public cache.
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
strategy:
matrix:
include:
@@ -21,17 +24,14 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Nix
uses: cachix/install-nix-action@v27
- name: Install Nix and set up cache
uses: logos-co/setup-nix-cache-action@v1
with:
extra_nix_config: |
attic-token-ci: ${{ secrets.ATTIC_TOKEN_CI }}
attic-token-public: ${{ secrets.ATTIC_TOKEN_PUBLIC }}
extra-nix-config: |
experimental-features = nix-command flakes
- uses: cachix/cachix-action@v15
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build
run: nix build --print-build-logs
+7 -7
View File
@@ -38,6 +38,9 @@ concurrency:
jobs:
doctests:
name: doc-tests (${{ matrix.os }})
# ATTIC_TOKEN_PUBLIC only exists in the public-cache environment; master
# jobs must opt into it to publish to the public cache.
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
strategy:
fail-fast: false
matrix:
@@ -50,14 +53,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
- name: Set up Nix cache
uses: logos-co/setup-nix-cache-action@v1
with:
name: logos-co
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
attic-token-ci: ${{ secrets.ATTIC_TOKEN_CI }}
attic-token-public: ${{ secrets.ATTIC_TOKEN_PUBLIC }}
# Resolve the commit under test. For pull requests this is the PR's head
# commit (not the synthetic merge commit); for pushes it's the pushed
+10
View File
@@ -56,6 +56,9 @@ on:
jobs:
build-appimage:
# ATTIC_TOKEN_PUBLIC only exists in the public-cache environment; master
# jobs must opt into it to publish to the public cache.
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
strategy:
# Don't let a failure in the under-validation logosctl block a
# logoscore release.
@@ -75,6 +78,9 @@ jobs:
- uses: logos-co/setup-nix-cache-action@v1
with:
attic-token-ci: ${{ secrets.ATTIC_TOKEN_CI }}
attic-token-public: ${{ secrets.ATTIC_TOKEN_PUBLIC }}
extra-nix-config: |
experimental-features = nix-command flakes
- name: Build ${{ matrix.tool.output }}
run: nix build .#${{ matrix.tool.output }} -L
@@ -102,6 +108,9 @@ jobs:
path: ${{ matrix.tool.bin }}-*-linux.tar.gz
build-macos-bundle:
# ATTIC_TOKEN_PUBLIC only exists in the public-cache environment; master
# jobs must opt into it to publish to the public cache.
environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}
strategy:
fail-fast: false
matrix:
@@ -116,6 +125,7 @@ jobs:
- uses: logos-co/setup-nix-cache-action@v1
with:
attic-token-ci: ${{ secrets.ATTIC_TOKEN_CI }}
attic-token-public: ${{ secrets.ATTIC_TOKEN_PUBLIC }}
- name: Build ${{ matrix.tool.output }}
run: nix build .#${{ matrix.tool.output }} -L
+36 -13
View File
@@ -76,19 +76,38 @@ else()
endif()
# ── SDK stack (imported targets) ──────────────────────────────────────────────
# Since the Qt split: logos-qt-sdk carries the Qt developer layer (provider
# objects, LogosAPI) and chains logos-protocol (transports, bridge helpers,
# logos-qt-host (from logos-plugin-qt) is the Qt HOST RUNTIME: LogosAPI,
# LogosAPIProvider and the LogosProviderObject base the in-process core
# service derives. It chains logos-protocol (transports, bridge helpers,
# logos::transportSetToJsonString). The Qt-free logos-cpp-sdk is header-only.
#
# This used to be logos-qt-sdk::logos_qt_sdk. The host runtime moved out of
# logos-qt-sdk into logos-plugin-qt; nothing else logos-qt-sdk ships (the Qt
# consumer emitter, the LpBridge headers, LogosUiPluginContext) is used here,
# so logos-qt-sdk is not a dependency of this repo at all any more.
if(NOT DEFINED LOGOS_PROTOCOL_ROOT AND DEFINED ENV{LOGOS_PROTOCOL_ROOT})
set(LOGOS_PROTOCOL_ROOT "$ENV{LOGOS_PROTOCOL_ROOT}")
endif()
if(NOT DEFINED LOGOS_QT_SDK_ROOT AND DEFINED ENV{LOGOS_QT_SDK_ROOT})
set(LOGOS_QT_SDK_ROOT "$ENV{LOGOS_QT_SDK_ROOT}")
if(NOT DEFINED LOGOS_QT_HOST_ROOT AND DEFINED ENV{LOGOS_QT_HOST_ROOT})
set(LOGOS_QT_HOST_ROOT "$ENV{LOGOS_QT_HOST_ROOT}")
endif()
# Fail loudly rather than letting find_package fall through to a system
# prefix (or, worse, silently skipping a target-guarded block later on).
if(NOT DEFINED LOGOS_QT_HOST_ROOT OR LOGOS_QT_HOST_ROOT STREQUAL "")
message(FATAL_ERROR
"LOGOS_QT_HOST_ROOT is not set. Pass -DLOGOS_QT_HOST_ROOT=<path to the "
"logos-qt-host prefix from logos-plugin-qt>, or set it in the environment.")
endif()
find_package(logos-protocol REQUIRED
PATHS "${LOGOS_PROTOCOL_ROOT}/lib/cmake/logos-protocol" NO_DEFAULT_PATH)
find_package(logos-qt-sdk REQUIRED
PATHS "${LOGOS_QT_SDK_ROOT}/lib/cmake/logos-qt-sdk" NO_DEFAULT_PATH)
find_package(logos-qt-host REQUIRED
PATHS "${LOGOS_QT_HOST_ROOT}/lib/cmake/logos-qt-host" NO_DEFAULT_PATH)
if(NOT TARGET logos-qt-host::logos_qt_host)
message(FATAL_ERROR
"find_package(logos-qt-host) succeeded but did not define "
"logos-qt-host::logos_qt_host — refusing to build a logoscore without "
"the Qt host runtime.")
endif()
# ── nlohmann_json (header-only) ───────────────────────────────────────────────
find_package(nlohmann_json REQUIRED)
@@ -148,6 +167,7 @@ set(SHARED_SOURCES
# Daemon path
src/daemon/daemon.cpp
src/daemon/daemon_state.cpp
src/daemon/access_policy_arg.cpp
src/daemon/port_allocator.cpp
src/daemon/token_store.cpp
src/daemon/log_sink.cpp
@@ -202,14 +222,17 @@ target_link_libraries(${_tgt} PRIVATE
Qt${QT_VERSION_MAJOR}::RemoteObjects
)
# Link the SDK stack (provides LogosProviderObject, bridge helpers,
# logos::transportSetToJsonString, and other symbols needed by core_service).
target_link_libraries(${_tgt} PRIVATE logos-qt-sdk::logos_qt_sdk)
# Link the Qt host runtime (provides LogosAPI, LogosAPIProvider,
# LogosProviderObject). It PUBLIC-links logos-protocol, which is where the
# bridge helpers and logos::transportSetToJsonString come from. Both
# front-ends need it: the shared core_service is what derives from
# LogosProviderObject.
target_link_libraries(${_tgt} PRIVATE logos-qt-host::logos_qt_host)
if(WIN32)
# This binary links liblogos_core.dll, which since the single-provider
# change EXPORTS the shared C++ runtime -- so the static archives behind
# logos-qt-sdk::logos_qt_sdk must stop providing it too, or every exported
# logos-qt-host::logos_qt_host must stop providing it too, or every exported
# symbol is defined twice and the link fails. See
# cmake/LogosSharedFromDll.cmake. The link interface above is deliberately
# left intact: the imported targets still carry their include dirs and
@@ -217,7 +240,7 @@ if(WIN32)
target_compile_definitions(${_tgt} PRIVATE LOGOS_SHARED_USE_DLL)
include("${CMAKE_CURRENT_LIST_DIR}/cmake/LogosSharedFromDll.cmake")
logos_use_shared_runtime_from_dll(
logos-qt-sdk::logos_qt_sdk
logos-qt-host::logos_qt_host
logos-protocol::logos_protocol)
endif()
@@ -231,8 +254,8 @@ endif()
target_include_directories(${_tgt} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${LOGOS_CPP_SDK_ROOT}/include/cpp
${LOGOS_QT_SDK_ROOT}/include
${LOGOS_QT_SDK_ROOT}/include/cpp
${LOGOS_QT_HOST_ROOT}/include
${LOGOS_QT_HOST_ROOT}/include/cpp
${LOGOS_PROTOCOL_ROOT}/include
${LOGOS_PROTOCOL_ROOT}/include/cpp
${LOGOS_LIBLOGOS_ROOT}/include
+3 -2
View File
@@ -1,8 +1,9 @@
# logos-logoscore-cli
The headless CLI runtime for the [Logos](https://github.com/logos-co) modular
application platform. It loads Logos modules (Qt plugins) and lets you call
their methods from the command line — no GUI needed.
application platform. It loads Logos modules Qt plugins and Qt-free
`universal` / `cdylib` ones alike — and lets you call their methods from the
command line, no GUI needed.
This repo is one of two frontends for [logos-liblogos](https://github.com/logos-co/logos-liblogos):
- **logos-logoscore-cli** (this repo) — headless CLI runtime for scripting, testing, and headless deployments
+8 -3
View File
@@ -55,9 +55,14 @@ function(logos_use_shared_runtime_from_dll)
endif()
foreach(_tgt IN LISTS ARGN)
if(TARGET ${_tgt})
set_target_properties(${_tgt} PROPERTIES IMPORTED_LOCATION "${_stub}")
message(STATUS "Windows: ${_tgt} provided by liblogos_core.dll, static archive suppressed")
if(NOT TARGET ${_tgt})
message(FATAL_ERROR
"logos_use_shared_runtime_from_dll: ${_tgt} is not a target. It must be "
"an IMPORTED target whose archive can be replaced by the empty stand-in; "
"skipping it would leave a static copy of the shared runtime in this "
"image alongside liblogos_core.dll's exported one.")
endif()
set_target_properties(${_tgt} PROPERTIES IMPORTED_LOCATION "${_stub}")
message(STATUS "Windows: ${_tgt} provided by liblogos_core.dll, static archive suppressed")
endforeach()
endfunction()
+54 -12
View File
@@ -622,19 +622,58 @@ Daemon startup options:
-m, --modules-dir <path> Directory to scan for modules (repeatable)
--persistence-path <path> Base directory for module instance persistence
(default: ~/.logoscore/data)
--access-policy <arg> Inter-module access policy: a path to a JSON
--access-policy <arg> Inter-module access policy. `enforce` turns on
deny-by-default; also accepts a path to a JSON
file, or inline JSON (mode + per-target caller
allowlists). See "Access policy" below.
allowlists). Default: none (no enforcement).
See "Access policy" below.
```
#### Access policy
`--access-policy` installs an inter-module access policy that declares,
per target module, which caller modules are allowed to invoke it. The
argument is resolved as **a path to a JSON file** when it doesn't begin
with `{`, or as **inline JSON** when it does. The resolved document is
validated as parseable JSON before the daemon boots; a bad path or
malformed JSON aborts startup.
**Default: off.** Without `--access-policy`, any loaded module may call any
other — unchanged from every earlier release.
##### Turning deny-by-default on
```bash
logoscore -D -m ./modules --access-policy enforce
```
That arms **deny-by-default**: a module may only call the modules it declared
as dependencies in its `metadata.json`. The runtime derives each target's
allowed callers from the live dependency graph (its loaded dependents, plus the
trusted `core` / `core_service`) and registers them with `capability_module`,
which then refuses to mint a token for anyone else — so a call from an
undeclared caller can never proceed.
A refusal is logged by `capability_module` with **both** module names, so a
denial never presents as a mysteriously empty result:
```
[capability_module] access policy denies 'test_basic_module' -> 'test_extlib_module'
```
The daemon also states which side it landed on at startup, so a policy that
failed to arm is visible rather than silently permissive:
```
Inter-module access enforcement is ON (mode=enforce): deny-by-default — ...
Inter-module access enforcement is OFF (no access policy set): ...
```
> **Before you flip it on:** modules that call targets they never declared will
> start being refused. Out-of-process `ui_qml` plugins are the known case — they
> are not tracked as dependents in the core registry, so they need an explicit
> `restrictions` entry (below). Check a deployment against the log first.
##### Full policy documents
`--access-policy` also accepts a policy document, which declares per target
module which caller modules may invoke it. The argument is resolved as the
literal **`enforce`**, else **a path to a JSON file** when it doesn't begin with
`{`, else **inline JSON**. The resolved document is validated as parseable JSON
before the daemon boots; a bad path or malformed JSON aborts startup.
```json
{
@@ -647,6 +686,13 @@ malformed JSON aborts startup.
}
```
`mode` is the switch: only `"enforce"` activates gating, and `--access-policy
enforce` is shorthand for exactly `{"version":1,"mode":"enforce","restrictions":{}}`.
An entry in `restrictions` **replaces** the derived allow-list for that target
verbatim — that is the escape hatch for a caller that legitimately cannot
declare its target. `capability_module`, `core` and `core_service` are never
restricted as targets.
```bash
# From a file
logoscore -D -m ./modules --access-policy ./policy.json
@@ -660,10 +706,6 @@ The policy is handed to the runtime (via `logos_core_set_access_policy`)
before any module is loaded, and is persisted with `--persist-config`
like the other daemon flags.
> **Note:** enforcement is not yet implemented on the runtime side — the
> policy is currently accepted and validated but **not enforced** (the
> underlying `logos_core_set_access_policy` is a no-op for now).
> **Note:** the legacy inline mode (`-c "module.method(args)"` / `--quit-on-finish`,
> which ran calls in a single short-lived process) has been removed. Use a daemon
> plus `logoscore call ...` as shown above.
+5 -3
View File
@@ -617,9 +617,11 @@ access_policy: |
The string is handed to the runtime (via `logos_core_set_access_policy`)
before any module is loaded.
> **Note:** enforcement is not yet implemented on the runtime side — the
> policy is currently accepted and validated but **not enforced** (the
> underlying `logos_core_set_access_policy` is a no-op for now).
> **Note:** the value must be a full policy document. The `enforce` shorthand
> is a `logoscore` flag spelling (`--access-policy enforce`); here, write the
> equivalent document — `{"version":1,"mode":"enforce","restrictions":{}}` —
> which arms deny-by-default with the allow-lists derived from each module's
> declared dependencies.
> **Note:** the legacy inline mode (`-c "module.method(args)"` / `--quit-on-finish`,
> which ran calls in a single short-lived process) has been removed. Use a daemon
+177 -82
View File
@@ -12,23 +12,32 @@ This project will live in its own repository. liblogos is consumed as an externa
## Project Structure
```
logosctl-cli/
logos-logoscore-cli/
├── src/ # All CLI source code
│ ├── main.cpp # Entry point — detects mode, dispatches
│ ├── main.cpp # logosctl entry point — detects mode, dispatches
│ ├── main_legacy.cpp # logoscore entry point — same daemon/client/core_service
│ │ # code below, frozen surface (see the README)
│ ├── config.cpp/h # Token + config file resolution
│ ├── paths.cpp/h # Executable/bundle-relative path resolution (no Qt)
│ │
│ ├── daemon/ # Daemon path (logosctl daemon start)
│ │ ├── daemon.cpp/h # Start core, load core_service, run event loop,
│ │ ├── daemon.cpp/h # Start core, register core_service, run event loop,
│ │ │ # open each --module-transport listener
│ │ ├── daemon_state.cpp/h # DaemonConfig (config.json) + DaemonRuntimeState
│ │ │ # (state.json) — operator preferences (writes only
│ │ │ # on --persist-config) + live runtime state.
│ │ ├── access_policy_arg.cpp/h # Resolve --access-policy into the JSON document
│ │ │ # handed to logos_core_set_access_policy()
│ │ ├── log_sink.cpp/h # Pipe-based capture of daemon + module-host
│ │ │ # stdout/stderr into a rotating log file
│ │ ├── port_allocator.cpp/h # Reserve an ephemeral TCP port before spawning a child
│ │ └── token_store.cpp/h # Named-token table — TokensFile owns daemon/tokens.json
│ │ # (hashed entries) + raw daemon/tokens/<name>.json
│ │
│ ├── client/ # Client path (all subcommands)
│ │ ├── client.cpp/h # Read <configDir>/client/config.json, connect to
│ │ ├── client.cpp/h # Client interface + RpcClient — connect to the
│ │ │ # daemon's core_service via LogosAPIClient
│ │ ├── client_state.cpp/h # Read/write <configDir>/client/config.json (dial spec)
│ │ ├── output.cpp/h # Output formatter (human / JSON / NDJSON)
│ │ └── commands/ # Subcommand implementations
│ │ ├── command.cpp/h # Base command class
@@ -42,28 +51,48 @@ logosctl-cli/
│ │ ├── watch_command.cpp/h
│ │ ├── stats_command.cpp/h
│ │ ├── stop_command.cpp/h
│ │ ├── package_command.cpp/h # install / remove / update (plan + apply)
│ │ ├── catalog_command.cpp/h # Browse + download from the online catalog
│ │ ├── config_command.cpp/h # Inspect/edit the config tree
│ │ ├── issue_token_command.cpp/h # Mints named tokens (daemon/tokens/<name>.json)
│ │ ├── revoke_token_command.cpp/h # Revokes by name
│ │ └── list_tokens_command.cpp/h # Lists issued tokens (name + metadata, no plaintext)
│ │
│ └── core_service/ # Built-in module — CLI ↔ daemon RPC gateway
│ ├── core_service_impl.h # LOGOS_PROVIDER class with LOGOS_METHOD declarations
│ ├── core_service_impl.h # Plain C++ class deriving LogosProviderObject; its
│ │ # public methods ARE the API (no marker macro — there
│ │ # used to be LOGOS_PROVIDER/LOGOS_METHOD here)
│ ├── core_service_impl.cpp # Method implementations (delegates to liblogos C API)
│ ├── core_service_loader.h # LogosProviderPlugin loader
│ ├── package_ops.cpp/h # Daemon-side plan/apply for package operations
│ ├── metadata.json # Plugin metadata
│ └── core_service_dispatch.cpp # Manual callMethod/getMethods dispatch
│ └── core_service_dispatch.cpp # Hand-written callMethodStd/getMethodsStd dispatch
│ # (no core_service_loader.h: the daemon registers the
│ # object in-process, it is never discovered as a plugin)
├── tests/
│ ├── test_core_service.cpp # core_service method tests
│ ├── test_cli_commands.cpp # Mode detection, subcommand dispatch
│ ├── test_cli_output.cpp # Output formatter tests
│ ├── test_cli_daemon.cpp # Daemon lifecycle, state file
── test_cli_client.cpp # Client connection to core_service
│ ├── test_commands.cpp # Subcommands against a mock Client
│ ├── test_mode_detection.cpp # Mode detection, subcommand dispatch
│ ├── test_output.cpp # Output formatter tests
│ ├── test_daemon_state.cpp # daemon/state.json + tokens round-trip
── test_token_store.cpp # Token issue / revoke / list / persistence
│ ├── test_config.cpp # Token + config-dir resolution
│ ├── test_paths.cpp # Executable/bundle path resolution
│ ├── test_port_allocator.cpp # Ephemeral-port allocation
│ ├── test_access_policy_arg.cpp # --access-policy argument resolution
│ ├── test_log_sink.cpp # Log capture + rotation
│ ├── test_cli.cpp # End-to-end logosctl CLI
│ ├── test_integration.cpp # logosctl against a live daemon
│ ├── test_cli_logoscore.cpp # End-to-end logoscore CLI (frozen duplicate)
│ └── test_integration_logoscore.cpp # logoscore against a live daemon (frozen duplicate)
├── docs/
│ ├── index.md # Doc index
│ ├── spec.md # CLI specification (user-facing behavior)
── project.md # This file (implementation details)
── project.md # This file (implementation details)
│ ├── logoscore.md # logoscore user guide
│ └── logosctl.md # logosctl user guide
├── doctests/ # Executable documentation specs
├── CMakeLists.txt # Build configuration
├── flake.nix # Nix flake
└── nix/ # Nix build modules
@@ -74,14 +103,20 @@ logosctl-cli/
| Dependency | Type | Purpose |
|---|---|---|
| **liblogos** | C library (external) | Core runtime: plugin discovery, loading, dependency resolution, event loop, process stats |
| **logos-cpp-sdk** | C++ library (external) | RPC client/provider classes: LogosAPI, LogosAPIClient, LogosProviderBase, TokenManager |
| **logos-cpp-generator** | Build tool (external) | Code generator for LOGOS_METHOD dispatch tables |
| **logos-cpp-sdk** | C++ library (external) | Qt-free SDK surface the CLI links directly (`logos_sdk`), incl. `logos::transportSetToJsonString` |
| **logos-protocol** | C++ library (external) | Transport + provider protocol: `LogosProviderObject`, `ModuleProxy`, `TokenManager` |
| **logos-qt-host** (in logos-plugin-qt) | C++ library (external) | The Qt host runtime the daemon and its in-process core service are built on: `LogosAPI`, `LogosAPIClient`, `LogosAPIProvider`. Comes from `logos-plugin-qt`, not `logos-qt-sdk` |
| **Qt6 Core** | Framework | Event loop, JSON handling, process management |
| **Qt6 RemoteObjects** | Framework | IPC between daemon and module host processes |
| **CMake 3.14+** | Build system | — |
| **Google Test** | Test framework | — |
| **Nix** | Package manager | Reproducible builds |
**No code generator is in this build.** `core_service` used to be listed here
as depending on `logos-cpp-generator` to emit a `LOGOS_METHOD` dispatch table;
that marker macro and that generator mode are gone from this repo's path —
`core_service_dispatch.cpp` is hand-written (see *Build integration* below).
### liblogos C API surface used
The CLI uses these functions from liblogos (declared in `logos_core.h`):
@@ -219,7 +254,7 @@ main.cpp
transport config (LogosAPI itself stays on the local-socket default)
6. Authenticate with token
→ Command::execute(args)
1. Call LOGOS_METHOD on core_service via LogosAPIClient
1. Call a core_service method by name via LogosAPIClient
2. Format result (human / JSON)
3. Print to stdout, exit
```
@@ -253,7 +288,13 @@ daemon starts clean; `-m`/`--persistence-path` configure daemon startup only
## CoreService Module
The `core_service` module is the RPC gateway between CLI clients and the daemon. It is a proper Logos module built with the new SDK API (`LOGOS_PROVIDER`, `LOGOS_METHOD`), but it lives in the CLI codebase (not in liblogos) because it is the CLI's concern — it exists to serve CLI clients.
The `core_service` module is the RPC gateway between CLI clients and the daemon. It is a proper Logos module — it implements the same `LogosProviderObject` interface the runtime calls on every module — but it lives in the CLI codebase (not in liblogos) because it is the CLI's concern — it exists to serve CLI clients.
Its business methods are **Qt-free**: they take and return `std::string` /
`LogosMap` / `LogosList` / `StdLogosResult`, and the Qt side of
`LogosProviderObject` is satisfied by trivial delegates in
`core_service_dispatch.cpp`. Its plain public methods *are* its API — see
**Definition** below for what replaced the old marker-macro spelling.
### Why a module?
@@ -270,48 +311,85 @@ The `core_service` module is the RPC gateway between CLI clients and the daemon.
```cpp
#include <logos_provider_object.h>
class CoreServiceImpl : public LogosProviderBase
class CoreServiceImpl : public LogosProviderObject
{
LOGOS_PROVIDER(CoreServiceImpl, "core_service", "1.0.0")
public:
// Emitted events go out through this hook, installed by
// setEventListenerStd() (see core_service_dispatch.cpp).
std::function<void(const std::string& eventName,
const std::string& data)> emitEvent;
// Module lifecycle
LOGOS_METHOD QVariant loadModule(const QString& name);
LOGOS_METHOD QVariant unloadModule(const QString& name);
LOGOS_METHOD QVariant reloadModule(const QString& name);
StdLogosResult loadModule(const std::string& name);
// withDependents cascades the unload to dependents, leaves-first.
StdLogosResult unloadModule(const std::string& name, bool withDependents);
StdLogosResult reloadModule(const std::string& name);
// Re-scan the module directories so packages installed since boot
// become discoverable without restarting the daemon.
LogosMap refreshModules();
// Package operations, split plan/apply so the client can prompt.
LogosMap planPackageOperation(const std::string& op,
const LogosList& names, const LogosMap& opts);
LogosMap applyPackageOperation(const std::string& op,
const LogosList& names, const LogosMap& opts);
LogosMap downloadPackage(const std::string& name, const LogosMap& opts);
// Queries
LOGOS_METHOD QJsonArray listModules(const QString& filter);
LOGOS_METHOD QJsonObject getStatus();
LOGOS_METHOD QJsonObject getModuleInfo(const QString& name);
LOGOS_METHOD QJsonArray getModuleStats();
LogosList listModules(const std::string& filter);
LogosMap getStatus();
LogosMap getModuleInfo(const std::string& name);
LogosList getModuleStats();
// Proxied call — delegates to target module
LOGOS_METHOD QVariant callModuleMethod(const QString& module,
const QString& method,
const QVariantList& args);
StdLogosResult callModuleMethod(const std::string& module,
const std::string& method,
const LogosList& args);
// Event forwarding
LOGOS_METHOD bool watchModuleEvents(const QString& module,
const QString& eventName);
bool watchModuleEvents(const std::string& module,
const std::string& eventName);
// Daemon lifecycle
LOGOS_METHOD QJsonObject shutdown();
LogosMap shutdown();
protected:
void onInit(LogosAPI* api) override;
void onInit(LogosAPI* api);
// LogosProviderObject — Qt side (trivial delegates to the std bridge)
QVariant callMethod(const QString& methodName, const QVariantList& args) override;
QJsonArray getMethods() override;
QString providerName() const override;
QString providerVersion() const override;
void setEventListener(EventCallback callback) override;
bool informModuleToken(const QString& moduleName, const QString& token) override;
void init(void* apiInstance) override;
// LogosProviderObject — universal (Qt-free) dispatch
nlohmann::json callMethodStd(const std::string& methodName,
const nlohmann::json& args) override;
std::vector<LogosMethodMetadata> getMethodsStd() override;
void setEventListenerStd(UniversalEventCallback callback) override;
private:
EventCallback m_eventCallback;
LogosAPI* m_api = nullptr;
};
```
There is no marker macro on these declarations. (There used to be a
`LOGOS_PROVIDER(...)` line and a `LOGOS_METHOD` prefix on each callable
method, scanned by a code generator; neither is used here any more.)
### How each method works
| LOGOS_METHOD | What it does (daemon-side) |
| Method | What it does (daemon-side) |
|---|---|
| `loadModule(name)` | Calls `logos_core_load_module(name, true)`. Returns `{"status":"ok","module":"...","version":"...","dependencies_loaded":[...]}` |
| `unloadModule(name)` | Calls `logos_core_unload_module(name, false)`. Returns `{"status":"ok","module":"..."}` |
| `unloadModule(name, withDependents)` | Calls `logos_core_unload_module(name, withDependents)`. With `withDependents` (the CLI default) liblogos cascades the unload to every module that depends on `name`, leaves-first, so nothing is left talking to a dead provider. Returns `{"status":"ok","module":"...","dependents_unloaded":[...]}` |
| `refreshModules()` | Re-scans the daemon's module directories so a package installed since boot becomes loadable without a restart — this is what lets `install` be followed by `load` in one session |
| `planPackageOperation(op, names, opts)` / `applyPackageOperation(op, names, opts)` | Daemon-side plan/apply for `install` / `remove` / `update` (see `src/core_service/package_ops.h`). The split exists so the client can show what would change and prompt; `--dry-run` stops after the plan |
| `downloadPackage(name, opts)` | Fetches a `.lgx` without installing it. Daemon-side because the downloader drops the file in the *daemon's* `$TMPDIR`, so the move to the requested directory has to happen on that host |
| `reloadModule(name)` | Checks if loaded/crashed → unload if needed → load. Returns result with `previous_status`. Non-destructive on failure: if the module was loaded before and the reload's load step fails, it attempts to restore the prior instance and reports `restored: true/false` plus an explanatory error rather than leaving the module down |
| `listModules(filter)` | Calls `logos_core_get_modules_info()` (name + loaded flag + embedded metadata per module). Emits `version` from metadata + status enum. Returns JSON array |
| `getStatus()` | Reads daemon state (PID, uptime, version) + calls `listModules("all")`. Returns `{"daemon":{...},"modules_summary":{...},"modules":[...]}` |
@@ -321,30 +399,23 @@ private:
| `watchModuleEvents(module, event)` | Registers an event listener on the target module via `m_api->getClient(module)->onEvent()`. Forwards received events by calling `emitEvent()` on core_service, which the CLI client receives over its own event subscription |
| `shutdown()` | Schedules `QCoreApplication::quit()` after a 200ms delay (to allow the RPC response to be sent), then the daemon performs its normal cleanup (unload modules, remove `daemon/state.json`, exit) |
### Loader
### Loader — there isn't one
**Files:** `src/core_service/core_service_loader.h`
```cpp
class CoreServiceLoader : public QObject, public PluginInterface, public LogosProviderPlugin
{
Q_OBJECT
Q_PLUGIN_METADATA(IID LogosProviderPlugin_iid FILE "metadata.json")
Q_INTERFACES(PluginInterface LogosProviderPlugin)
public:
QString name() const override { return "core_service"; }
QString version() const override { return "1.0.0"; }
LogosProviderObject* createProviderObject() override {
return new CoreServiceImpl();
}
};
```
`core_service` is **not** discovered as a plugin, so it has no
`Q_PLUGIN_METADATA` loader class. (This section used to document a
`src/core_service/core_service_loader.h` holding a `CoreServiceLoader :
QObject, PluginInterface, LogosProviderPlugin` with a `createProviderObject()`
factory. That file no longer exists.) The daemon constructs `CoreServiceImpl`
itself and hands it to the provider — see **Registration (daemon-side)** below.
### Metadata
**Files:** `src/core_service/metadata.json`
Declarative identity only — nothing in the build reads it, because there is no
plugin to attach it to. `CoreServiceImpl::name()` / `::version()` return the
same values in code.
```json
{
"name": "core_service",
@@ -361,28 +432,37 @@ The daemon registers `core_service` as an in-process module during startup, befo
```cpp
// In Daemon::start()
auto* coreServiceApi = new LogosAPI("core_service");
auto* coreServiceApi = new LogosAPI("core_service", coreTransports);
auto* coreServiceImpl = new CoreServiceImpl();
coreServiceImpl->init(coreServiceApi);
auto* provider = coreServiceApi->getProvider();
// Accept operator-issued named tokens, not just the boot `auto` token.
// Installed before registerObject so the proxy is validated from its
// first published call.
provider->setTokenValidator(...);
provider->registerObject("core_service", static_cast<LogosProviderObject*>(coreServiceImpl));
```
This registers the module directly into the runtime using the C++ SDK classes (`LogosAPI`, `LogosAPIProvider`) without directory scanning. The daemon also saves a client token via `TokenManager::instance().saveToken("cli_client", token)` so CLI clients can authenticate.
This registers the module directly into the runtime using the Qt host classes (`LogosAPI`, `LogosAPIProvider`) without directory scanning. The daemon also saves a client token via `TokenManager::instance().saveToken("cli_client", token)` so CLI clients can authenticate.
### Build integration
The `core_service_dispatch.cpp` file provides a manual `callMethod()` dispatch table and `getMethods()` metadata for `CoreServiceImpl`. Unlike dynamically loaded modules that use `logos-cpp-generator`, core_service uses a hand-written dispatch because it is statically linked into the daemon binary.
The `core_service_dispatch.cpp` file provides a hand-written `callMethodStd()` dispatch table and `getMethodsStd()` metadata for `CoreServiceImpl`, plus the trivial Qt-side delegates (`callMethod`, `getMethods`, `setEventListener`, …) that bridge to them. Dynamically loaded modules get this glue generated for them from their header; core_service is written by hand because it is statically linked into the daemon binary and never goes through a module build.
The dispatch wraps argument coercion in a try/catch: a malformed RPC (e.g. a number where a string arg is expected, which makes `args[i].get<std::string>()` throw `nlohmann::json::type_error`) is converted into a structured `{status:"error", code:"INVALID_ARGS", message:...}` response instead of an uncaught exception that would propagate through the Qt event loop and terminate the whole daemon. This keeps one authenticated client from crashing the daemon with a single bad argument.
```cpp
// core_service_dispatch.cpp — maps method names to CoreServiceImpl methods
QVariant CoreServiceImpl::callMethod(const QString& method, const QVariantList& args) {
if (method == "loadModule") return loadModule(args.value(0).toString());
if (method == "shutdown") return QVariant::fromValue(shutdown());
nlohmann::json CoreServiceImpl::callMethodStd(const std::string& methodName,
const nlohmann::json& args) {
try {
if (methodName == "loadModule" && args.size() >= 1)
return stdLogosResultToJson(loadModule(args[0].get<std::string>()));
if (methodName == "shutdown") return shutdown();
// ... etc
return nullptr;
} catch (const std::exception& e) { /* -> INVALID_ARGS envelope */ }
}
```
@@ -594,9 +674,12 @@ For `tcp_ssl`, each module entry also accepts `"ca": "<path>"` and
**Files:** `src/client/client.cpp/h`
**Purpose:** Connect to the daemon's `core_service` module via `LogosAPIClient` and invoke its LOGOS_METHODs.
**Purpose:** Connect to the daemon's `core_service` module via `LogosAPIClient` and invoke its methods by name.
The client is a thin wrapper around `LogosAPIClient`. Each method maps 1:1 to a LOGOS_METHOD on `core_service`:
`Client` is an abstract interface (so tests can substitute a mock);
`RpcClient` is the real implementation and is a thin wrapper around
`LogosAPIClient`. Its surface is **Qt-free**`std::string` in, `LogosMap` /
`LogosList` out — and each method maps 1:1 to a `core_service` method:
**API:**
@@ -604,22 +687,29 @@ The client is a thin wrapper around `LogosAPIClient`. Each method maps 1:1 to a
|--------|---------------------------|
| `Client::connect() -> bool` | Read `<configDir>/client/config.json`, set `LOGOS_INSTANCE_ID` from `instance_id`, build `LogosTransportConfig` from the dial spec, load token from `token_file` (or `LOGOSCTL_TOKEN` env), create `LogosAPIClient` targeting `"core_service"`, authenticate |
| `Client::isConnected() -> bool` | — |
| `Client::loadModule(name) -> QVariant` | `core_service.loadModule(name)` |
| `Client::unloadModule(name) -> QVariant` | `core_service.unloadModule(name)` |
| `Client::reloadModule(name) -> QVariant` | `core_service.reloadModule(name)` |
| `Client::listModules(filter) -> QJsonArray` | `core_service.listModules(filter)` |
| `Client::getStatus() -> QJsonObject` | `core_service.getStatus()` |
| `Client::getModuleInfo(name) -> QJsonObject` | `core_service.getModuleInfo(name)` |
| `Client::getModuleStats() -> QJsonArray` | `core_service.getModuleStats()` |
| `Client::callModuleMethod(module, method, args) -> QVariant` | `core_service.callModuleMethod(module, method, args)` |
| `Client::shutdown() -> QJsonObject` | `core_service.shutdown()` |
| `Client::watchModuleEvents(module, event, callback)` | `core_service.watchModuleEvents(module, event)` + event subscription |
| `Client::lastError() -> std::string` | — (last connect/RPC failure reason) |
| `Client::loadModule(name) -> LogosMap` | `core_service.loadModule(name)` |
| `Client::unloadModule(name, withDependents) -> LogosMap` | `core_service.unloadModule(name, withDependents)` |
| `Client::reloadModule(name) -> LogosMap` | `core_service.reloadModule(name)` |
| `Client::refreshModules() -> LogosMap` | `core_service.refreshModules()` |
| `Client::planPackageOperation(op, names, opts) -> LogosMap` | `core_service.planPackageOperation(...)` |
| `Client::applyPackageOperation(op, names, opts) -> LogosMap` | `core_service.applyPackageOperation(...)` |
| `Client::downloadPackage(name, opts) -> LogosMap` | `core_service.downloadPackage(name, opts)` |
| `Client::listModules(filter) -> LogosList` | `core_service.listModules(filter)` |
| `Client::getStatus() -> LogosMap` | `core_service.getStatus()` |
| `Client::getModuleInfo(name) -> LogosMap` | `core_service.getModuleInfo(name)` |
| `Client::getModuleStats() -> LogosList` | `core_service.getModuleStats()` |
| `Client::callModuleMethod(module, method, args) -> LogosMap` | `core_service.callModuleMethod(module, method, args)` |
| `Client::shutdown() -> LogosMap` | `core_service.shutdown()` |
| `Client::watchModuleEvents(module, event, callback) -> bool` | `core_service.watchModuleEvents(module, event)` + event subscription |
**Implementation pattern:**
```cpp
QVariant Client::loadModule(const QString& name) {
return m_apiClient->invokeRemoteMethod("core_service", "loadModule", name);
LogosMap RpcClient::loadModule(const std::string& name) {
nlohmann::json ret = d->invoke("loadModule", nlohmann::json::array({name}));
if (ret.is_object()) return ret;
return LogosMap{{"status","error"},{"code","RPC_FAILED"}, /* ... */};
}
```
@@ -687,7 +777,7 @@ Parallel daemons run side-by-side when invoked with distinct `--config-dir` valu
## CLI Commands
All client-path commands connect to the daemon's `core_service` module via `LogosAPIClient` and call its LOGOS_METHODs. They never call liblogos C API functions directly.
All client-path commands connect to the daemon's `core_service` module via `LogosAPIClient` and call its methods by name. They never call liblogos C API functions directly.
### logosctl daemon
@@ -700,7 +790,7 @@ logosctl daemon [--modules-dir <path>]...
**Behavior:**
1. `logos_core_init(argc, argv)`, add module directories, `logos_core_start()`
2. Register `core_service` in-process via `logos_core_register_module()`
2. Register `core_service` in-process via `LogosAPIProvider::registerObject()` (not `logos_core_register_module()`, which only maps a plugin *name* to a file path for on-disk discovery)
3. Write `~/.logosctl/daemon/state.json` (listeners + hashed-token table) and emit `~/.logosctl/client/config.json` + `~/.logosctl/client/auto.json` for the local client
4. `logos_core_exec()` (Qt event loop — blocks)
5. On SIGINT/SIGTERM: `logos_core_cleanup()`, remove `daemon/state.json`, exit
@@ -914,18 +1004,18 @@ Only the daemon path calls liblogos C API functions directly:
| Daemon operation | liblogos functions |
|---|---|
| Start core | `logos_core_init`, `logos_core_add_modules_dir`, `logos_core_start` |
| Register core_service | `LogosAPI`, `LogosAPIProvider::registerObject` (C++ SDK) |
| Register core_service | `LogosAPI`, `LogosAPIProvider::registerObject` (Qt host runtime, `logos-qt-host`) |
| Run event loop | `logos_core_exec` |
| Shutdown | `logos_core_cleanup` |
### Client path — core_service method mapping
Client commands call core_service LOGOS_METHODs, which delegate to liblogos internally:
Client commands call core_service methods, which delegate to liblogos internally:
| CLI command | core_service method | liblogos function called internally |
|---|---|---|
| `load-module` | `loadModule(name)` | `logos_core_load_module(name, true)` |
| `unload-module` | `unloadModule(name)` | `logos_core_unload_module(name, false)` |
| `unload-module` | `unloadModule(name, withDependents)` | `logos_core_unload_module(name, withDependents)` — liblogos does the leaves-first cascade |
| `reload-module` | `reloadModule(name)` | `logos_core_unload_module(name, false)` + `logos_core_load_module(name, true)` |
| `list-modules` | `listModules(filter)` | `logos_core_get_known_modules`, `logos_core_get_loaded_modules` |
| `status` | `getStatus()` | reads daemon state + `listModules` |
@@ -1104,13 +1194,18 @@ done
| `test_token_store.cpp` | Token issuance (including `--expires` and `--local-only`), duplicate-name rejection (unless `--replace`), revocation, list, persistence round-trip. Confirms `tokens.json["tokens"]` stores hashes only; plaintext lives in `daemon/tokens/<name>.json`. Fail-closed invariants: an empty token never authenticates, `issueToken` Ok implies a non-empty token, a failed `--replace` preserves the prior raw token, and issuing against an unsupported-schema-version file refuses instead of clobbering it. |
| `test_config.cpp` | Token resolution order (env var → `client/<token_file>`); `client/config.json` parsing; `clientTokenPath` accepts plain filenames and rejects path-traversal (`../`, absolute, sub-dirs). |
| `test_port_allocator.cpp` | Ephemeral-port allocation: bad host returns 0, an IPv6 any-address (`::`) allocates a port, consecutive allocations are distinct. |
| `test_access_policy_arg.cpp` | `--access-policy` resolution: the `enforce` alias expands to the deny-by-default document, the alias beats the file branch, inline JSON and file paths pass through unchanged, and a bad path / malformed JSON fails with a reason rather than degrading to "no policy". |
| `test_log_sink.cpp` | Pipe-based stdout/stderr capture into the rotating daemon log. |
| `test_paths.cpp` | Executable / bundle-relative path resolution (`paths.h`). |
| `test_cli.cpp` | End-to-end CLI tests: help, version, no-args, client commands without daemon, daemon startup with --verbose; rejection of an invalid `--module-transport` port, an invalid `--client-codec`, and a `--token-file` that carries no usable token. |
| `test_integration.cpp` | Daemon-backed integration: a real `logosctl` daemon against a real module directory, driven through the client subcommands — error paths, the full `test_basic_module` API surface, event subscription via `watch`, and many simultaneous clients on one daemon. |
| `test_cli_logoscore.cpp` / `test_integration_logoscore.cpp` | The same two suites frozen against `logoscore`'s surface, so shared-runtime changes can't regress the tool people actually use. They get deleted with the binary. |
---
## Known Issues
1. **Event forwarding** — The `watch` command requires `core_service` to forward events from target modules to CLI clients. The approach is: `core_service.watchModuleEvents()` registers a listener on the target module via `LogosAPIClient::onEvent()`, then re-emits received events via `LogosProviderBase::emitEvent()`. The CLI client subscribes to `core_service` events. This creates a relay chain (target module → core_service → CLI client) which adds latency. An alternative would be having the CLI client connect directly to the target module, but that bypasses the core_service gateway pattern.
1. **Event forwarding** — The `watch` command requires `core_service` to forward events from target modules to CLI clients. The approach is: `core_service.watchModuleEvents()` registers a listener on the target module via `LogosAPIClient::onEvent()`, then re-emits received events through `CoreServiceImpl::emitEvent` — the `std::function` hook the runtime installs via `setEventListenerStd()`, emitted under the name `module_event`. The CLI client subscribes to `core_service` events. This creates a relay chain (target module → core_service → CLI client) which adds latency. An alternative would be having the CLI client connect directly to the target module, but that bypasses the core_service gateway pattern.
2. **Stale state file** — If the daemon crashes without removing `<configDir>/daemon/state.json` (and the auto-emitted `client/` tree), the files stay on disk. Clients no longer pre-probe PID liveness (that only works for local daemons); instead the first RPC fails with a connect error and the `status` command turns that into a "not running" report. The only cost of a stale file is that the first attempt after a crash wastes one RPC timeout; in practice that's fine.
@@ -1124,7 +1219,7 @@ done
2. **TUI mode** — Interactive terminal UI with autocomplete (like Obsidian CLI).
3. **Batch mode** — Execute multiple commands from a file (`logosctl batch commands.txt`).
4. **`module-logs` command** — Stream or tail module process logs (`logosctl module-logs chat --tail 50`). Referenced by error messages but not yet specified.
5. **Extract core_service** — If core_service grows, it could be extracted into a standalone plugin loaded from disk rather than statically linked. The LOGOS_PROVIDER API makes this trivial.
5. **Extract core_service** — If core_service grows, it could be extracted into a standalone plugin loaded from disk rather than statically linked. It already implements the plain `LogosProviderObject` interface, so the extraction is mostly adding a plugin entry point and a module build.
6. **Capability-scoped tokens** — Today all tokens are admin-equivalent. Named tokens (`issue-token --name …`) create separate identities but each one is still fully authorised against the daemon. A scope/capability system would let e.g. a read-only token call `list-modules` / `status` but reject `load-module` / `stop`.
7. **Client-cert TLS** — The `tcp_ssl` transport today authenticates the daemon to the client (server cert); mutual TLS + client-cert auth would be a natural extension once we have scoped tokens, and subsumes the token-file distribution problem for many deployments.
+6 -2
View File
@@ -837,8 +837,12 @@ verbatim. Each entry carries `name`, `signature`, `returnType`, `isInvokable`,
The `events` array is the module's `getPluginEvents` introspection. Each entry
carries `name`, `signature`, `parameters` (each `{name, type}`), and — when the
event is documented — `description`. There is no `returnType`/`isInvokable`:
events are void. Modules with no declared events report an empty array (legacy
`provider` modules always do).
events are void. Modules with no declared events report an empty array legacy
Q_INVOKABLE modules (`interface: "legacy"`) always do, since they have no
`logos_events:` section for the introspection to read. (This used to say
"legacy `provider` modules"; `interface: "provider"` is no longer a buildable
module kind — the module builder refuses it and points at
`interface: "universal"`.)
**Crashed module (JSON):**
```json
Generated
+592296 -27257
View File
File diff suppressed because it is too large Load Diff
+79 -17
View File
@@ -8,12 +8,45 @@
# public symbols (e.g. logos::transportSetToJsonString) without
# relying on the symbol surviving liblogos_core's link-time
# dead-strip. liblogos's own SDK pin still drives transitive deps.
# Master-tracking. This was rev-pinned at a04b2788, the b3 codegen tip the
# rest of this stack (logos-plugin-qt's qt-host, logos-liblogos) was built
# against while the capability split lived only on that branch. It has
# merged (logos-cpp-sdk#138, master 95d7b3a): master carries
# cpp/logos_host_services.h and the rest of the split, so the pin's whole
# rationale is gone. Verified against master's FILES, not by ancestry —
# #138 was SQUASH-merged, so `merge-base --is-ancestor a04b2788 master` is
# correctly false even though every line of it is in master.
logos-cpp-sdk.url = "github:logos-co/logos-cpp-sdk";
logos-cpp-sdk.inputs.logos-protocol.follows = "logos-protocol";
# Master-tracking. This was rev-pinned at c8bab128, on logos-protocol's
# per-client token store branch, because logos-qt-host calls
# TokenManager::forIdentity/isolateIdentity and an older or default-branch
# logos-protocol failed to COMPILE logos-qt-host. That branch has merged
# (logos-protocol#59, master f4407ff): master's cpp/token_manager.h has
# forIdentity/isolateIdentity and cpp/logos_protocol.h has
# lp_grant_host_services/lp_token_keys, so an unpinned URL can no longer
# walk this back off the token-store surface. Checked by reading master's
# files — #59 was squash-merged, so ancestry says nothing here.
logos-protocol.url = "github:logos-co/logos-protocol";
logos-qt-sdk.url = "github:logos-co/logos-qt-sdk";
logos-qt-sdk.inputs.logos-protocol.follows = "logos-protocol";
logos-qt-sdk.inputs.logos-cpp-sdk.follows = "logos-cpp-sdk";
# The Qt HOST RUNTIME — LogosAPI, LogosAPIProvider, LogosProviderObject —
# which the daemon and its in-process core service are built on. It lives
# in logos-plugin-qt as `logos-qt-host`, not in logos-qt-sdk; this repo
# needs nothing else out of logos-qt-sdk (it emits no Qt consumer
# wrappers and has no UI plugin), so that input is gone entirely.
#
# The rev pin that used to sit here (cc24fa1c) is retired: the host split
# HAS landed on logos-plugin-qt's default branch (logos-plugin-qt#19,
# master 9b2c64e). nix/qt-host.nix is on master and flake.nix publishes
# `logos-qt-host` through forAllTargets, so `packages.x86_64-windows
# .logos-qt-host` — which the Windows leg below names — resolves too.
# Confirmed by fetching master's files; #19 was squash-merged, so the
# commit is not an ancestor of master and ancestry is the wrong test.
logos-plugin-qt.url = "github:logos-co/logos-plugin-qt";
logos-plugin-qt.inputs.logos-nix.follows = "logos-nix";
logos-plugin-qt.inputs.logos-protocol.follows = "logos-protocol";
# Rev-pinned at the liblogos that is itself built on logos-qt-host: it and
# this CLI share one host runtime in one process image, so they cannot be
# allowed to drift apart.
logos-liblogos.url = "github:logos-co/logos-liblogos";
# liblogos is linked INTO this CLI, so its logos-protocol is the one the
# crashing code path actually runs. Without this follows it brought its own,
@@ -22,7 +55,16 @@
# nothing. Measured: 4 SIGSEGVs in 2000 client calls with the root pin
# already on the fixed protocol.
logos-liblogos.inputs.logos-protocol.follows = "logos-protocol";
logos-capability-module.url = "github:logos-co/logos-capability-module";
# Rev-pinned at capability_module's master tip, which is also what
# logos-liblogos and logos-standalone-app lock — one capability_module
# across the stack. NOT the `interface: "universal"` port (07dba1f): that
# one declares metadata.json#host_services and fails closed until a host
# calls logos_module_grant_host_services, and nothing in this stack does
# yet (grep: neither logos-liblogos nor logos-plugin-qt calls it). Under
# that build the daemon's capability gate refuses EVERY requestModule with
# "not granted the token_registry host service", so no module can call
# another. Bump this once the granting side lands in the host.
logos-capability-module.url = "github:logos-co/logos-capability-module/0cb33fb21c689076295ad6a75eaf1188012aa5fe";
# Bundled alongside capability_module so the CLI can manage packages
# itself: package_manager installs/uninstalls and owns the dependency
# graph, package_downloader owns the catalogs and downloads. Same pair
@@ -32,13 +74,27 @@
logos-package-downloader-module.url = "github:logos-co/logos-package-downloader-module";
# Real test-module plugins (test_basic_module) used by the
# daemon-backed integration tests in tests/test_integration.cpp.
logos-test-modules.url = "github:logos-co/logos-test-modules";
#
# Rev-pinned, and the pin is load-bearing rather than cosmetic. These
# plugins are loaded BY the daemon this repo builds, so they and it share
# one host runtime in one process image — the same constraint that already
# rev-pins logos-liblogos above. a639b934 is the b4 tip that links the test
# modules against logos-qt-host (not logos-qt-sdk) and carries the matching
# B4 stack pins; master (f8077fab) predates that repoint and would load
# plugins built against the other host.
#
# Why the URL and not the lock: an UNPINNED url resolves to the default
# branch, and f8077fab IS master's tip — so `nix flake update
# logos-test-modules` here is a silent no-op that leaves the ten b4 commits
# behind while reporting success. f8077fab is a strict ancestor of
# a639b934 (verified, non-shallow clone), so this is forward-only.
logos-test-modules.url = "github:logos-co/logos-test-modules/a639b93475bf135d283288c31b8499b7f4d09f92";
nix-bundle-logos-module-install.url = "github:logos-co/nix-bundle-logos-module-install";
nix-bundle-dir.url = "github:logos-co/nix-bundle-dir";
nix-bundle-appimage.url = "github:logos-co/nix-bundle-appimage";
};
outputs = { self, nixpkgs, logos-nix, logos-cpp-sdk, logos-protocol, logos-qt-sdk, logos-liblogos, logos-capability-module, logos-package-manager-module, logos-package-downloader-module, logos-test-modules, nix-bundle-logos-module-install, nix-bundle-dir, nix-bundle-appimage }:
outputs = { self, nixpkgs, logos-nix, logos-cpp-sdk, logos-protocol, logos-plugin-qt, logos-liblogos, logos-capability-module, logos-package-manager-module, logos-package-downloader-module, logos-test-modules, nix-bundle-logos-module-install, nix-bundle-dir, nix-bundle-appimage }:
let
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
# Build info baked into the logosctl binary so `--version` reports the
@@ -59,7 +115,7 @@
{ name = "logos-liblogos"; commit = revOf logos-liblogos; }
{ name = "logos-cpp-sdk"; commit = revOf logos-cpp-sdk; }
{ name = "logos-protocol"; commit = revOf logos-protocol; }
{ name = "logos-qt-sdk"; commit = revOf logos-qt-sdk; }
{ name = "logos-plugin-qt"; commit = revOf logos-plugin-qt; }
{ name = "logos-capability-module"; commit = revOf logos-capability-module; }
{ name = "logos-package-manager-module"; commit = revOf logos-package-manager-module; }
{ name = "logos-package-downloader-module"; commit = revOf logos-package-downloader-module; }
@@ -70,7 +126,7 @@
pkgs = import nixpkgs { inherit system; };
cppSdk = logos-cpp-sdk.packages.${system}.default;
protocolPkg = logos-protocol.packages.${system}.default;
qtSdk = logos-qt-sdk.packages.${system}.default;
qtHost = logos-plugin-qt.packages.${system}.logos-qt-host;
liblogos = logos-liblogos.packages.${system}.logos-liblogos;
liblogosLib = logos-liblogos.packages.${system}.logos-liblogos-lib;
liblogosPortable = logos-liblogos.packages.${system}.portable;
@@ -119,7 +175,7 @@
else import nixpkgs { inherit system; };
cppSdk = logos-cpp-sdk.packages.${system}.default;
protocolPkg = logos-protocol.packages.${system}.default;
qtSdk = logos-qt-sdk.packages.${system}.default;
qtHost = logos-plugin-qt.packages.${system}.logos-qt-host;
liblogos = logos-liblogos.packages.${system}.logos-liblogos;
liblogosLib = logos-liblogos.packages.${system}.logos-liblogos-lib;
liblogosPortable = logos-liblogos.packages.${system}.portable;
@@ -150,7 +206,7 @@
});
in
{
packages = forAllTargets ({ pkgs, system, cppSdk, protocolPkg, qtSdk, liblogos, liblogosLib, liblogosPortable, capabilityModuleLib, packageManagerModuleLib, packageManagerModuleLibPortable, packageDownloaderModuleLib, installDev, installPortable, dirBundler, appBundler }:
packages = forAllTargets ({ pkgs, system, cppSdk, protocolPkg, qtHost, liblogos, liblogosLib, liblogosPortable, capabilityModuleLib, packageManagerModuleLib, packageManagerModuleLibPortable, packageDownloaderModuleLib, installDev, installPortable, dirBundler, appBundler }:
let
pname = "logos-logoscore-cli";
# VERSION is only present on release branches; dev branches use a placeholder.
@@ -255,7 +311,7 @@
pkgs.qt6.qtremoteobjects
cppSdk
protocolPkg
qtSdk
qtHost
pkgs.stduuid
pkgs.cli11
pkgs.fmt
@@ -284,7 +340,7 @@
# itself reference and would otherwise be dead-stripped).
"-DLOGOS_CPP_SDK_ROOT=${cppSdk}"
"-DLOGOS_PROTOCOL_ROOT=${protocolPkg}"
"-DLOGOS_QT_SDK_ROOT=${qtSdk}"
"-DLOGOS_QT_HOST_ROOT=${qtHost}"
];
};
@@ -438,7 +494,7 @@
pkgs.qt6.qtremoteobjects
cppSdk
protocolPkg
qtSdk
qtHost
liblogosLib
pkgs.yaml-cpp
pkgs.spdlog
@@ -601,7 +657,7 @@ ${pkgs.lib.optionalString withPkgModules ''
# see the `build` derivation above for the rationale.
cppSdk
protocolPkg
qtSdk
qtHost
];
cmakeFlags = [
@@ -609,7 +665,7 @@ ${pkgs.lib.optionalString withPkgModules ''
"-DLOGOS_LIBLOGOS_ROOT=${liblogos}"
"-DLOGOS_CPP_SDK_ROOT=${cppSdk}"
"-DLOGOS_PROTOCOL_ROOT=${protocolPkg}"
"-DLOGOS_QT_SDK_ROOT=${qtSdk}"
"-DLOGOS_QT_HOST_ROOT=${qtHost}"
];
installPhase = ''
@@ -709,7 +765,7 @@ ${pkgs.lib.optionalString withPkgModules ''
pkgs.qt6.qtremoteobjects
cppSdk
protocolPkg
qtSdk
qtHost
pkgs.gtest
pkgs.stduuid
pkgs.cli11
@@ -723,7 +779,7 @@ ${pkgs.lib.optionalString withPkgModules ''
"-DLOGOS_LIBLOGOS_ROOT=${liblogosPortable}"
"-DLOGOS_CPP_SDK_ROOT=${cppSdk}"
"-DLOGOS_PROTOCOL_ROOT=${protocolPkg}"
"-DLOGOS_QT_SDK_ROOT=${qtSdk}"
"-DLOGOS_QT_HOST_ROOT=${qtHost}"
];
};
@@ -952,6 +1008,12 @@ ${pkgs.lib.optionalString withPkgModules ''
paths = [
(installDev capabilityModuleLib)
logos-test-modules.modules.${system}.test_basic_module.install
# The access-policy tests need a declared and an undeclared
# (caller, target) pair from real module metadata:
# test_ipc_new_api_module declares [test_basic_module, test_extlib_module]
# test_basic_module declares [] → basic -> extlib is undeclared
logos-test-modules.modules.${system}.test_extlib_module.install
logos-test-modules.modules.${system}.test_ipc_new_api_module.install
];
};
in rec {
+55
View File
@@ -0,0 +1,55 @@
#include "daemon/access_policy_arg.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <fstream>
#include <sstream>
namespace logoscore {
std::optional<std::string> resolveAccessPolicyArg(const std::string& arg,
std::string* error)
{
auto fail = [&](std::string why) -> std::optional<std::string> {
if (error) *error = std::move(why);
return std::nullopt;
};
// Checked before the file branch, so `--access-policy enforce` never gets
// read as a relative path named "enforce".
if (arg == kEnforceAlias)
return std::string(kEnforceEnvelope);
std::string content;
std::string source; // for diagnostics
auto firstNonSpace = std::find_if(arg.begin(), arg.end(),
[](unsigned char c) { return !std::isspace(c); });
const bool looksInline = (firstNonSpace != arg.end() && *firstNonSpace == '{');
if (looksInline) {
content = arg;
source = "inline --access-policy JSON";
} else {
std::ifstream ifs(arg, std::ios::binary);
if (!ifs)
return fail("--access-policy file '" + arg + "' could not be opened.");
std::ostringstream ss;
ss << ifs.rdbuf();
content = ss.str();
source = "--access-policy file '" + arg + "'";
}
// Parse-check only; schema enforcement is the runtime's job.
try {
(void)nlohmann::json::parse(content);
} catch (const std::exception& e) {
return fail(source + " is not valid JSON: " + e.what());
}
return content;
}
} // namespace logoscore
+38
View File
@@ -0,0 +1,38 @@
#ifndef LOGOSCORE_DAEMON_ACCESS_POLICY_ARG_H
#define LOGOSCORE_DAEMON_ACCESS_POLICY_ARG_H
#include <optional>
#include <string>
namespace logoscore {
// The bare deny-by-default document. `mode` is the runtime's own switch (see
// liblogos access_policy.h): "enforce" turns restrictions into denials, and
// with no explicit `restrictions` the runtime derives them from the declared
// dependency graph — a module may only call the modules it declared. This
// spelling exists so an operator can arm that without hand-writing JSON; it is
// NOT a second switch, it expands to exactly this document.
inline constexpr const char* kEnforceAlias = "enforce";
inline constexpr const char* kEnforceEnvelope =
R"({"version":1,"mode":"enforce","restrictions":{}})";
// Resolve the operator's --access-policy argument into the JSON document
// handed to logos_core_set_access_policy():
//
// "enforce" -> kEnforceEnvelope (deny-by-default)
// text starting with '{' -> inline JSON, used as-is
// anything else -> a path to a JSON file, read from disk
//
// The result is parse-checked (schema enforcement is the runtime's job).
// Returns nullopt on a path that cannot be opened or content that is not valid
// JSON, with a human-readable reason in `error` when non-null.
//
// Absent the flag entirely, the daemon installs no policy at all — enforcement
// off, which is the pre-existing behaviour. This function is only reached when
// the operator asked for something.
std::optional<std::string> resolveAccessPolicyArg(const std::string& arg,
std::string* error = nullptr);
} // namespace logoscore
#endif // LOGOSCORE_DAEMON_ACCESS_POLICY_ARG_H
+8 -36
View File
@@ -27,6 +27,7 @@
#include "daemon/daemon.h"
#include "daemon/daemon_state.h"
#include "daemon/log_sink.h"
#include "daemon/access_policy_arg.h"
#include "client/client_state.h"
#include "client/client.h"
#include "client/output.h"
@@ -211,44 +212,15 @@ static std::string preScanConfigDir(int argc, char* argv[])
return {};
}
// Resolve the --access-policy argument: inline JSON if it starts with
// '{', otherwise a path to read from disk. Parse-checks the result and
// returns nullopt (after a stderr diagnostic) on any error.
// Resolve the --access-policy argument (see daemon/access_policy_arg.h for the
// three accepted spellings) and print the reason on stderr if it can't be.
static std::optional<std::string> resolveAccessPolicy(const std::string& arg)
{
std::string content;
std::string source; // for diagnostics
auto firstNonSpace = std::find_if(arg.begin(), arg.end(),
[](unsigned char c) { return !std::isspace(c); });
const bool looksInline = (firstNonSpace != arg.end() && *firstNonSpace == '{');
if (looksInline) {
content = arg;
source = "inline --access-policy JSON";
} else {
std::ifstream ifs(arg, std::ios::binary);
if (!ifs) {
std::cerr << "Error: --access-policy file '" << arg
<< "' could not be opened." << std::endl;
return std::nullopt;
}
std::ostringstream ss;
ss << ifs.rdbuf();
content = ss.str();
source = "--access-policy file '" + arg + "'";
}
// Parse-check only; schema enforcement is the runtime's job.
try {
(void)nlohmann::json::parse(content);
} catch (const std::exception& e) {
std::cerr << "Error: " << source << " is not valid JSON: "
<< e.what() << std::endl;
return std::nullopt;
}
return content;
std::string error;
auto resolved = logoscore::resolveAccessPolicyArg(arg, &error);
if (!resolved)
std::cerr << "Error: " << error << std::endl;
return resolved;
}
// Collapse the two-token group verbs into the single tokens CLI11 has
+15 -39
View File
@@ -17,6 +17,7 @@
#include "platform_compat.h"
#include "daemon/daemon.h"
#include "daemon/daemon_state.h"
#include "daemon/access_policy_arg.h"
#include "client/client_state.h"
#include "client/client.h"
#include "client/output.h"
@@ -71,44 +72,15 @@ static std::string preScanConfigDir(int argc, char* argv[])
return {};
}
// Resolve the --access-policy argument: inline JSON if it starts with
// '{', otherwise a path to read from disk. Parse-checks the result and
// returns nullopt (after a stderr diagnostic) on any error.
// Resolve the --access-policy argument (see daemon/access_policy_arg.h for the
// three accepted spellings) and print the reason on stderr if it can't be.
static std::optional<std::string> resolveAccessPolicy(const std::string& arg)
{
std::string content;
std::string source; // for diagnostics
auto firstNonSpace = std::find_if(arg.begin(), arg.end(),
[](unsigned char c) { return !std::isspace(c); });
const bool looksInline = (firstNonSpace != arg.end() && *firstNonSpace == '{');
if (looksInline) {
content = arg;
source = "inline --access-policy JSON";
} else {
std::ifstream ifs(arg, std::ios::binary);
if (!ifs) {
std::cerr << "Error: --access-policy file '" << arg
<< "' could not be opened." << std::endl;
return std::nullopt;
}
std::ostringstream ss;
ss << ifs.rdbuf();
content = ss.str();
source = "--access-policy file '" + arg + "'";
}
// Parse-check only; schema enforcement is the runtime's job.
try {
(void)nlohmann::json::parse(content);
} catch (const std::exception& e) {
std::cerr << "Error: " << source << " is not valid JSON: "
<< e.what() << std::endl;
return std::nullopt;
}
return content;
std::string error;
auto resolved = logoscore::resolveAccessPolicyArg(arg, &error);
if (!resolved)
std::cerr << "Error: " << error << std::endl;
return resolved;
}
int main(int argc, char *argv[])
@@ -174,11 +146,15 @@ int main(int argc, char *argv[])
auto* persistencePathOpt = app.add_option("--persistence-path", persistencePath,
"Base directory for module instance persistence (default: ~/.logoscore/data)");
// --access-policy: inter-module access policy (file path or inline
// JSON). Daemon-only; forwarded to the runtime before modules load.
// --access-policy: inter-module access policy (the literal `enforce`, a
// file path, or inline JSON). Daemon-only; forwarded to the runtime before
// modules load. Absent => no policy => enforcement off, as before.
std::string accessPolicyArg;
auto* accessPolicyOpt = app.add_option("--access-policy", accessPolicyArg,
"Inter-module access policy: path to a JSON file, or inline JSON "
"Inter-module access policy (default: none, no enforcement). "
"`enforce` turns on deny-by-default: a module may only call the "
"modules it declares as dependencies, and any other call is refused. "
"Also accepts a path to a JSON file, or inline JSON "
"(mode + per-target caller allowlists)");
// --access-group: share the daemon with an OS group. Sockets become
+2
View File
@@ -6,6 +6,7 @@ set(TEST_LIB_SOURCES
${CMAKE_SOURCE_DIR}/src/paths.cpp
${CMAKE_SOURCE_DIR}/src/yaml_json.cpp
${CMAKE_SOURCE_DIR}/src/daemon/daemon_state.cpp
${CMAKE_SOURCE_DIR}/src/daemon/access_policy_arg.cpp
${CMAKE_SOURCE_DIR}/src/daemon/port_allocator.cpp
${CMAKE_SOURCE_DIR}/src/daemon/token_store.cpp
${CMAKE_SOURCE_DIR}/src/daemon/log_sink.cpp
@@ -51,6 +52,7 @@ target_link_libraries(logosctl_testlib PUBLIC
add_executable(unit_tests
test_config.cpp
test_log_sink.cpp
test_access_policy_arg.cpp
test_daemon_state.cpp
test_paths.cpp
test_output.cpp
+148
View File
@@ -0,0 +1,148 @@
// =============================================================================
// Tests for the --access-policy argument resolver
// (src/daemon/access_policy_arg.{h,cpp}).
//
// This is the operator-facing half of deny-by-default enforcement: whatever
// this returns is handed verbatim to logos_core_set_access_policy(), where
// `mode: "enforce"` (and only that) arms the runtime. The tests pin:
// - `enforce` expands to a document the runtime reads as enforce mode
// - the alias wins over the file branch (no relative path named "enforce")
// - inline JSON and file paths are passed through unchanged
// - a bad path / malformed JSON fails with a reason instead of silently
// degrading to "no policy" (which would look exactly like flag-off)
// =============================================================================
#include <gtest/gtest.h>
#include "daemon/access_policy_arg.h"
#include <nlohmann/json.hpp>
#include <cstdint>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
using logoscore::resolveAccessPolicyArg;
namespace {
// Scratch directory that cleans itself up, so the file-path cases don't leave
// droppings in the build sandbox.
class TempDir {
public:
TempDir() {
base = fs::temp_directory_path() /
("logoscore_ap_" + std::to_string(::getpid()) + "_" +
std::to_string(reinterpret_cast<uintptr_t>(this)));
fs::create_directories(base);
}
~TempDir() { std::error_code ec; fs::remove_all(base, ec); }
fs::path write(const std::string& name, const std::string& content) const {
const fs::path p = base / name;
std::ofstream(p) << content;
return p;
}
fs::path base;
};
} // namespace
// ── The deny-by-default alias ────────────────────────────────────────────────
TEST(AccessPolicyArg, EnforceAliasExpandsToAnEnforcePolicy) {
std::string err;
auto resolved = resolveAccessPolicyArg("enforce", &err);
ASSERT_TRUE(resolved.has_value()) << err;
// The value only matters through the runtime's eyes: it must parse, and it
// must carry mode "enforce" — that field is what flips deny-by-default on.
const auto doc = nlohmann::json::parse(*resolved);
EXPECT_EQ(doc.value("mode", std::string{}), "enforce");
// No explicit restrictions: the runtime derives them from the declared
// dependency graph, which is what "deny-by-default" means here.
EXPECT_TRUE(doc.value("restrictions", nlohmann::json::object()).empty());
}
TEST(AccessPolicyArg, EnforceAliasIsNotTreatedAsAFilePath) {
// Even with a readable file literally named `enforce` next to the process,
// the alias must win — otherwise arming enforcement would depend on the
// daemon's working directory.
TempDir dir;
dir.write("enforce", R"({"version":1,"mode":"audit"})");
const fs::path prev = fs::current_path();
fs::current_path(dir.base);
std::string err;
auto resolved = resolveAccessPolicyArg("enforce", &err);
fs::current_path(prev);
ASSERT_TRUE(resolved.has_value()) << err;
EXPECT_EQ(nlohmann::json::parse(*resolved).value("mode", std::string{}), "enforce");
}
// ── Inline JSON ──────────────────────────────────────────────────────────────
TEST(AccessPolicyArg, InlineJsonPassesThroughUnchanged) {
const std::string inlineDoc =
R"({"version":1,"mode":"enforce","restrictions":{)"
R"("package_manager":{"allowedCallers":["package_manager_ui"]}}})";
std::string err;
auto resolved = resolveAccessPolicyArg(inlineDoc, &err);
ASSERT_TRUE(resolved.has_value()) << err;
EXPECT_EQ(*resolved, inlineDoc);
}
TEST(AccessPolicyArg, LeadingWhitespaceStillCountsAsInline) {
std::string err;
auto resolved = resolveAccessPolicyArg(" \n {\"version\":1,\"mode\":\"enforce\"}", &err);
ASSERT_TRUE(resolved.has_value()) << err;
EXPECT_NE(resolved->find("enforce"), std::string::npos);
}
// ── File paths ───────────────────────────────────────────────────────────────
TEST(AccessPolicyArg, FilePathIsReadFromDisk) {
TempDir dir;
const std::string doc =
R"({"version":1,"mode":"enforce","restrictions":{"t":{"allowedCallers":["c"]}}})";
const fs::path p = dir.write("policy.json", doc);
std::string err;
auto resolved = resolveAccessPolicyArg(p.string(), &err);
ASSERT_TRUE(resolved.has_value()) << err;
EXPECT_EQ(nlohmann::json::parse(*resolved).value("mode", std::string{}), "enforce");
}
// ── Failures are loud ────────────────────────────────────────────────────────
TEST(AccessPolicyArg, MissingFileFailsWithAReason) {
std::string err;
auto resolved = resolveAccessPolicyArg("/definitely/not/here/policy.json", &err);
EXPECT_FALSE(resolved.has_value());
EXPECT_NE(err.find("could not be opened"), std::string::npos) << err;
}
TEST(AccessPolicyArg, MalformedInlineJsonFailsWithAReason) {
std::string err;
auto resolved = resolveAccessPolicyArg("{not valid json", &err);
EXPECT_FALSE(resolved.has_value());
EXPECT_NE(err.find("not valid JSON"), std::string::npos) << err;
}
TEST(AccessPolicyArg, MalformedFileJsonFailsWithAReason) {
TempDir dir;
const fs::path p = dir.write("bad.json", "{oops");
std::string err;
auto resolved = resolveAccessPolicyArg(p.string(), &err);
EXPECT_FALSE(resolved.has_value());
EXPECT_NE(err.find("not valid JSON"), std::string::npos) << err;
}
TEST(AccessPolicyArg, NullErrorPointerIsAccepted) {
EXPECT_FALSE(resolveAccessPolicyArg("{nope", nullptr).has_value());
}
+189
View File
@@ -42,6 +42,8 @@
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
@@ -121,6 +123,7 @@ public:
cfg << "version: 2\n"
<< "modules_dirs:\n"
<< " - \"" << modulesDir.string() << "\"\n";
if (!extraConfig.empty()) cfg << extraConfig;
}
pid = spawnBg({"daemon", "start"}, daemonLog);
}
@@ -218,6 +221,12 @@ public:
// Optional private socket directory ($TMPDIR for the node). Empty ⇒
// inherit the ambient temp dir, which is what every non-socket test wants.
fs::path socketDir;
// Extra YAML appended to the daemon's config.yaml, verbatim. Set before
// start(). logosctl has no daemon flags — the session's config IS the
// surface — so this is how a test varies one daemon setting. The
// access-policy tests are the reason it exists: the SAME binaries, the
// SAME modules, with the policy as the only variable between two runs.
std::string extraConfig;
pid_t pid = -1;
};
@@ -982,3 +991,183 @@ TEST_F(SocketLifecycleTest, BootReapsStaleSocketsButSparesLiveOnesAndFiles)
<< "the reaper deleted a regular file sharing the prefix: " << plain;
EXPECT_EQ(slurp(plain), "not a socket") << "regular file was modified";
}
// ═══════════════════════════════════════════════════════════════════════════
// Deny-by-default inter-module access enforcement (`access_policy`)
// ═══════════════════════════════════════════════════════════════════════════
//
// The end-to-end proof, on a REAL daemon: same binaries, same modules, same
// call — the only variable between the fixtures below is whether the session's
// `access_policy` was set to the deny-by-default document. logosctl takes it
// from the daemon config rather than from a flag (`logoscore` spells the same
// document `--access-policy enforce`; see test_integration_logoscore.cpp).
//
// The probe is capability_module.requestModule(caller, target), which is the
// gate every inter-module call passes through: no token minted ⇒ the call can
// never proceed. Driving it directly (rather than through a module that calls
// out) keeps the assertion on the gate itself, with no module-side retry or
// timeout policy in the way.
//
// The (caller, target) pairs come from logos-test-modules' declared metadata:
// test_ipc_new_api_module declares [test_basic_module, test_extlib_module]
// test_basic_module declares [] ← so basic -> extlib is UNdeclared
//
// Both directions matter, and the DECLARED one carries the weight: an
// implementation that refused everything would satisfy the "denied" half on
// its own.
namespace {
// requestModule is a two-arg call returning the minted token, or "" on refusal.
// Returns nullopt if the client call itself failed (a broken daemon, not a
// policy decision) so the tests can tell those apart.
std::optional<std::string> requestModuleToken(const LogosctlDaemon& d,
const std::string& caller,
const std::string& target,
std::string* raw)
{
std::string out;
const std::string cmd =
"call capability_module requestModule " + caller + " " + target;
const int rc = d.run(cmd, &out, /*timeoutSecs=*/20);
if (raw) *raw = out;
if (rc != 0) return std::nullopt;
nlohmann::json env = lastJsonObject(out);
if (env.value("status", std::string{}) != "ok") return std::nullopt;
const nlohmann::json result = env.value("result", nlohmann::json{});
if (result.is_string()) return result.get<std::string>();
if (result.is_null()) return std::string{};
return std::nullopt;
}
// True when the daemon log carries an access-policy refusal naming BOTH
// modules. Matched structurally (a line that says it denied, mentioning caller
// and target) rather than by exact text: capability_module has more than one
// implementation in this tree and they quote the names differently ('x' vs
// "x"). What must not vary is that both names are on the line.
bool logDeniesPair(const std::string& log,
const std::string& caller,
const std::string& target)
{
std::istringstream in(log);
std::string line;
while (std::getline(in, line)) {
if (line.find("denies") == std::string::npos) continue;
const auto c = line.find(caller);
const auto t = line.find(target);
// Ordered caller-then-target: "denies A -> B" and "denies B -> A" are
// different claims, and only the first is the one under test.
if (c != std::string::npos && t != std::string::npos && c < t) return true;
}
return false;
}
// The bare deny-by-default document — the same text `logoscore
// --access-policy enforce` expands to (src/daemon/access_policy_arg.h). `mode`
// is the runtime's only switch; with no explicit `restrictions` the runtime
// derives them from the declared dependency graph.
constexpr const char* kEnforceDoc =
R"({"version":1,"mode":"enforce","restrictions":{}})";
// Bring up a daemon with the three modules loaded. `policyDoc` empty ⇒ no
// access_policy in the session config at all (today's default).
class AccessPolicyFixture : public ::testing::Test {
protected:
LogosctlDaemon d;
void bootWith(const std::string& policyDoc) {
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
if (!policyDoc.empty()) {
// A JSON document carried as a YAML string, exactly as
// docs/logosctl.md tells operators to write it.
d.extraConfig = std::string("access_policy: '") + policyDoc + "'\n";
}
d.start(::testing::UnitTest::GetInstance()->current_test_info()->name());
ASSERT_TRUE(d.waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(d.daemonLog);
// test_ipc_new_api_module declares both others, so one load pulls all three.
std::string out;
if (d.run("load-module test_ipc_new_api_module", &out, /*timeoutSecs=*/30) != 0)
GTEST_SKIP() << "test_ipc_new_api_module not available in this modules dir:\n"
<< out;
ASSERT_EQ(d.run("list-modules --loaded", &out), 0) << out;
for (const char* m : {"test_ipc_new_api_module", "test_basic_module", "test_extlib_module"})
ASSERT_NE(out.find(m), std::string::npos)
<< m << " must be loaded before probing the gate.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
}
void TearDown() override { d.shutdown(); }
};
} // namespace
// ── Policy unset: unchanged behaviour ───────────────────────────────────────
TEST_F(AccessPolicyFixture, NoPolicy_UndeclaredPairStillMintsAToken) {
bootWith("");
if (::testing::Test::IsSkipped() || ::testing::Test::HasFatalFailure()) return;
std::string raw;
// test_basic_module never declared test_extlib_module. Without a policy
// this is unrestricted, and it must STAY unrestricted — several modules in
// this tree call targets they never declared.
auto token = requestModuleToken(d, "test_basic_module", "test_extlib_module", &raw);
ASSERT_TRUE(token.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_FALSE(token->empty())
<< "an undeclared pair must still be minted with no access policy — "
"this is the pre-existing behaviour the default must preserve.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
// Nothing was denied, so nothing may be logged as denied either.
EXPECT_FALSE(logDeniesPair(slurp(d.daemonLog), "test_basic_module", "test_extlib_module"))
<< "no policy is installed — nothing should be denied.\n"
<< slurp(d.daemonLog);
}
// ── Policy armed: deny-by-default, both directions ──────────────────────────
TEST_F(AccessPolicyFixture, EnforcePolicy_RefusesUndeclaredPairAndLogsBothNames) {
bootWith(kEnforceDoc);
if (::testing::Test::IsSkipped() || ::testing::Test::HasFatalFailure()) return;
std::string raw;
auto token = requestModuleToken(d, "test_basic_module", "test_extlib_module", &raw);
ASSERT_TRUE(token.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_TRUE(token->empty())
<< "an undeclared pair must be REFUSED under an enforce policy.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
// A silent denial is the failure mode this codebase has been debugged for
// twice: it presents as a call returning empty. The refusal must name BOTH
// modules in the log.
const std::string log = slurp(d.daemonLog);
EXPECT_TRUE(logDeniesPair(log, "test_basic_module", "test_extlib_module"))
<< "the refusal must be logged with both module names.\n" << log;
}
TEST_F(AccessPolicyFixture, EnforcePolicy_StillAllowsADeclaredPair) {
bootWith(kEnforceDoc);
if (::testing::Test::IsSkipped() || ::testing::Test::HasFatalFailure()) return;
// The half that matters: enforcement that refused everything would pass
// the test above and break every real deployment. test_ipc_new_api_module DECLARED
// test_basic_module, so it must still be minted a token.
std::string raw;
auto token = requestModuleToken(d, "test_ipc_new_api_module", "test_basic_module", &raw);
ASSERT_TRUE(token.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_FALSE(token->empty())
<< "a DECLARED caller must still be allowed under enforce — otherwise "
"the policy just breaks everything.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
// …and the same daemon still refuses the undeclared pair, so the allow
// above is not "enforcement quietly failed to arm".
auto denied = requestModuleToken(d, "test_basic_module", "test_extlib_module", &raw);
ASSERT_TRUE(denied.has_value()) << raw;
EXPECT_TRUE(denied->empty())
<< "control: the undeclared pair must be refused on this same daemon.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
}
+110
View File
@@ -55,6 +55,8 @@
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
@@ -1191,3 +1193,111 @@ TEST_F(PersistencePathTest, TildeFlagExpandsAgainstHome) {
EXPECT_FALSE(fs::exists(defaultDataDir() / "test_basic_module"))
<< "module data went to the DEFAULT data dir despite --persistence-path";
}
// ═══════════════════════════════════════════════════════════════════════════
// `--access-policy enforce` — the shorthand, on a real daemon
// ═══════════════════════════════════════════════════════════════════════════
//
// The runtime gate itself is proved against the surviving tool in
// test_integration.cpp, which arms the same policy through the session config.
// What is logoscore-only, and therefore what this file has to cover, is the
// SPELLING: the literal `enforce` expanding to the deny-by-default document
// (src/daemon/access_policy_arg.h) and reaching the runtime from a flag.
//
// One daemon, both directions, because a shorthand that resolved to "refuse
// everything" would satisfy the denial on its own:
// test_ipc_new_api_module declares [test_basic_module, test_extlib_module]
// test_basic_module declares [] ← so basic -> extlib is UNdeclared
namespace {
// requestModule is the two-arg gate every inter-module call passes through:
// it returns the minted token, or "" when the policy refuses. nullopt means
// the client call itself failed (a broken daemon, not a policy decision).
std::optional<std::string> requestModuleToken(const LogoscoreDaemon& d,
const std::string& caller,
const std::string& target,
std::string* raw)
{
std::string out;
const std::string cmd =
"call capability_module requestModule " + caller + " " + target;
const int rc = d.run(cmd, &out, /*timeoutSecs=*/20);
if (raw) *raw = out;
if (rc != 0) return std::nullopt;
nlohmann::json env = lastJsonObject(out);
if (env.value("status", std::string{}) != "ok") return std::nullopt;
const nlohmann::json result = env.value("result", nlohmann::json{});
if (result.is_string()) return result.get<std::string>();
if (result.is_null()) return std::string{};
return std::nullopt;
}
// True when the daemon log carries a refusal naming BOTH modules, caller
// first. Matched structurally rather than by exact text: capability_module has
// more than one implementation in this tree and they quote the names
// differently ('x' vs "x"). What must not vary is that both names are there.
bool logDeniesPair(const std::string& log,
const std::string& caller,
const std::string& target)
{
std::istringstream in(log);
std::string line;
while (std::getline(in, line)) {
if (line.find("denies") == std::string::npos) continue;
const auto c = line.find(caller);
const auto t = line.find(target);
if (c != std::string::npos && t != std::string::npos && c < t) return true;
}
return false;
}
} // namespace
TEST(AccessPolicyFlagTest, EnforceShorthandDeniesUndeclaredAndKeepsDeclared) {
LogoscoreDaemon d;
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
d.extraArgs = {"--access-policy", "enforce"};
d.start("access_policy_enforce");
struct Cleanup { LogoscoreDaemon* p; ~Cleanup() { p->shutdown(); } } cleanup{&d};
ASSERT_TRUE(d.waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(d.daemonLog);
// test_ipc_new_api_module declares both others, so one load pulls all three.
std::string out;
if (d.run("load-module test_ipc_new_api_module", &out, /*timeoutSecs=*/30) != 0)
GTEST_SKIP() << "test_ipc_new_api_module not available in this modules dir:\n" << out;
ASSERT_EQ(d.run("list-modules --loaded", &out), 0) << out;
for (const char* m : {"test_ipc_new_api_module", "test_basic_module", "test_extlib_module"})
ASSERT_NE(out.find(m), std::string::npos)
<< m << " must be loaded before probing the gate.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
// Undeclared: refused, and the refusal names both modules. A silent denial
// presents as a call returning empty, which is the failure mode this
// codebase has been debugged for twice.
std::string raw;
auto denied = requestModuleToken(d, "test_basic_module", "test_extlib_module", &raw);
ASSERT_TRUE(denied.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_TRUE(denied->empty())
<< "an undeclared pair must be REFUSED under --access-policy enforce — "
"the shorthand did not arm the policy.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_TRUE(logDeniesPair(slurp(d.daemonLog), "test_basic_module", "test_extlib_module"))
<< "the refusal must be logged with both module names.\n"
<< slurp(d.daemonLog);
// Declared: still minted. This is the half that matters — a shorthand that
// refused everything would pass the assertion above and break every real
// deployment.
auto allowed = requestModuleToken(d, "test_ipc_new_api_module", "test_basic_module", &raw);
ASSERT_TRUE(allowed.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_FALSE(allowed->empty())
<< "a DECLARED caller must still be allowed under enforce — otherwise "
"the shorthand just breaks everything.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
}