* 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>
26 KiB
logosctl
The merged CLI: logoscore + lgpd + lgpm in one tool. It runs modules
like logoscore does, and it manages packages itself —
bundling the package_manager and package_downloader modules, so searching
a catalog, installing with dependencies resolved, loading and calling are all
one binary's job.
Being validated; not yet the default. It shares the runtime with
logoscore but no state — its session lives in ~/.logosctl, so nothing here
can disturb a logoscore deployment.
Build instructions, flake outputs and test targets are in the
main README. The .#ctl-* outputs are the ones that ship this
binary; the package commands need a portable build, because that is what
the public catalog ships.
Usage
logosctl runs as a daemon (long-running process) that you drive with client commands to load modules and call methods.
Daemon Mode
Start a daemon, then use client commands to manage modules and call methods.
Sessions
Everything logosctl needs lives in one directory — a session. --config-dir
picks which one (default ~/.logosctl, also LOGOSCTL_CONFIG_DIR):
<session>/
├── daemon/
│ ├── config.yaml # daemon configuration — you write this
│ ├── startup.err # transient: a --detach child's output before logging is up
│ ├── state.json # live instance: pid, instance id, bound ports
│ ├── tokens.json # hashed-at-rest accepted tokens
│ └── tokens/<name>.json
├── client/
│ ├── config.yaml # dial spec + token_file — you write this for remote use
│ └── auto.json # local token, rewritten by the daemon each boot
├── modules/ # core modules installed into this session
├── plugins/ # UI plugins installed into this session
├── keyring/ # trusted package-signing keys
├── logs/ # daemon.log -> daemon_<timestamp>.log, rotated
├── cache/downloads/ # fetched .lgx
└── data/ # per-module persistence
A session is portable by default: copy the directory and its packages,
catalogs and trust assumptions come with it. Two sessions can hold different
package sets and disagree about which signers they trust. The only things left
outside are the binary itself and the runtime sockets in $TMPDIR.
That default is not a cage — any of the subdirectories can be redirected:
dirs:
keyring: ~/.config/logos/trusted-keys # share trust across sessions
cache: /var/cache/logos # put downloads on a bigger disk
modules: /opt/logos/modules # a tree something else manages
plugins: plugins-custom # relative -> stays inside the session
data: /var/lib/logos/data
logs: /var/log/logos # ship logs where your collector looks
How a value is written decides whether portability survives:
| Form | Resolves to | |
|---|---|---|
plugins-custom |
<session>/plugins-custom |
still portable |
~/x |
$HOME/x |
outside the session |
/var/cache/logos |
as given | outside the session |
persistence_path is the older spelling of dirs.data and still works;
dirs.data wins if both are set.
Modules come from two places: the read-only set bundled beside the binary
(capability_module, package_manager, package_downloader) and the writable
<session>/modules. On a name collision the session's copy wins, which is how
you override a bundled module.
Starting the Daemon
logosctl daemon start # runs in the foreground
logosctl daemon start --detach # forks, returns once it is accepting commands
logosctl daemon status
logosctl daemon stop
--detach is not the same as &: backgrounding returns immediately, before the
transports have bound, so the next command races the boot. --detach returns
only after the daemon has published daemon/state.json, and tells you where it
is logging.
Logs
Everything the daemon and its module subprocesses write goes to a rotating file
under logs/:
logging:
enabled: true # false -> no log file at all
file: daemon.log # inside dirs.logs
max_size_mb: 10 # rotate past this; 0 = never rotate
max_files: 5 # keep this many in total, oldest dropped
console: true # mirror to the terminal (ignored once detached)
Each start writes a new, timestamped file, the same scheme Basecamp uses:
logs/
├── daemon.log -> daemon_20260729_180411.log # always the current session
├── daemon_20260729_180404.log
├── daemon_20260729_180409.log
└── daemon_20260729_180411.log
logging.file names the log; the start time is inserted into the real file, and
that exact name survives as a symlink to whichever file is current — so
tail -F logs/daemon.log follows across restarts without anyone working out a
stamp.
max_files bounds the directory, not just one session's rotations: the
oldest files are pruned at each start, so a daemon restarted a hundred times
does not leave a hundred logs. (spdlog's own retention only prunes within a
single sink's rotation set, which is why this is enforced separately.)
Capture is pipe-based, not a file redirect, and that is the point: module hosts are separate processes holding inherited descriptors. Redirecting to a file would catch their output but make rotation impossible — renaming a file out from under a child that has it open just keeps filling the old inode. A pipe puts one reader in charge, so rotation is safe and subprocess output still lands in the log.
The size cap and retention come from spdlog's rotating sink, which liblogos already logs through. Lines arriving from the pipe already carry their own timestamp and level, so they are written verbatim rather than stamped twice.
Configuration
Configuration is a YAML document you install into the session. It is never
passed alongside another command, so daemon start and every client command act
on the session exactly as it is on disk.
logosctl daemon config set ./node.yaml # also accepts @file, or - for stdin
logosctl daemon config show
set replaces the file wholesale — there is no merge — and validates the whole
document, through the loader the daemon itself uses, before writing anything. A
document it rejects is never installed, so the previous config stays intact and
the session is never left holding one the daemon would refuse to boot from.
Unknown keys are rejected by name, because the loader ignores what it does not
recognise and a silently-dropped insecureTcp (the key is insecure_tcp) would
leave the daemon running with your intent missing. A key whose value is the
wrong type is reported the same way — modules_dirs: /single/path (a scalar
where a list belongs) fails with modules_dirs: expected a list of strings, but got a string. rather than being ignored, half-applied, or fatal.
# node.yaml
insecure_tcp: false
dirs: # optional; each defaults to <session>/<name>
keyring: ~/.config/logos/trusted-keys
access_group: logos # share the daemon with an OS group (see below)
signature_policy: warn # none | warn | require
modules_dirs: # extra read-only module directories to scan
- /opt/logos/modules
ssl: # default TLS material for every tcp_ssl listener
cert: /etc/logos/tls/server.pem
key: /etc/logos/tls/server.key
modules:
core_service:
- protocol: tcp_ssl
host: 0.0.0.0
port: 8645
codec: json # json | cbor
capability_module:
- protocol: tcp_ssl
host: 0.0.0.0
port: 8646
cert: /etc/logos/tls/other.pem # overrides the ssl: block for this one
key: /etc/logos/tls/other.key
Per-listener keys are protocol, host, port, codec, cert, key,
ca_file and verify_peer. cert, key and ca_file are opened as
written — unlike dirs:, a relative one resolves against the daemon
process's working directory, not the session, so the same config started from
a different directory silently fails its TLS handshake
(use_certificate_chain_file: No such file or directory). Give them absolute
paths.
The top-level ssl: { cert, key, ca } block is the default for every
tcp_ssl listener in the document — the usual case, where one certificate
covers the whole node. A listener that names its own cert:/key:/ca_file:
keeps it; the merge is per field, so a listener that names only a cert: still
inherits the block's key:. A tcp_ssl listener left with no certificate from
either source is refused at startup rather than bound: it would accept
connections and fail every handshake with no shared cipher, which reads like
a client fault.
signature_policy: is handed to the session's package manager at daemon start
and decides what logosctl package install does with an unsigned package or
one signed by a key that is not in the session's keyring/: none skips the
check, warn (the default when the key is absent) installs and prints a
warning, require refuses the install. It takes effect on the next daemon
start, like everything else in this file.
If the value cannot be delivered to the package manager at startup — the module
did not load, or its object could not be acquired — the daemon unloads the
package manager rather than leave it enforcing its own warn default while
this file, logosctl config get and state.json all still say require.
Package commands are unavailable for that session, and the reason is on stderr.
A failure to set the directories is not treated the same way: every directory
fails closed on its own (an install refuses with User modules directory is not set), so that case warns and leaves the manager up.
A local listener is always added to every module, so same-host clients and the
daemon's own cross-module calls keep working whatever else you configure. Any
tcp/tcp_ssl entries are additional, outward-facing listeners.
Remote clients need capability_module exposed too, not just core_service.
Before its first RPC a client performs a requestModule handshake against
capability_module. On the same host that rides the free local listener; from
another host it has to reach capability_module over the network. Exposing only
core_service is the single most common remote-setup mistake — client commands
hang at connect time because the handshake never completes.
Plaintext tcp on a non-loopback host puts tokens on the wire in cleartext. The
daemon refuses to bind that unless insecure_tcp: true is set. Prefer
tcp_ssl, or a TLS terminator in front.
The client side is symmetric:
logosctl client config set ./client.yaml
logosctl client config show
# client.yaml — only needed to reach a daemon on another host
version: 2
token_file: alice.json # a bare filename, read from <session>/client/
daemon:
core_service: { transport: tcp_ssl, host: node.example, port: 8645, ca: /etc/logos/tls/ca.pem, verify_peer: true }
capability_module: { transport: tcp_ssl, host: node.example, port: 8646, ca: /etc/logos/tls/ca.pem, verify_peer: true }
The accepted top-level keys are exactly version, daemon, token_file and
instance_id. token_file must be a plain filename — anything containing a
separator or .. is refused and the client fails closed with "token file not
found". ca is opened as written, so give it an absolute path for the same
reason cert/key need one on the daemon side.
The two documents describe different ends of the same connection, and their key names were never unified. Per listener:
Daemon (modules:) |
Client (daemon:) |
|
|---|---|---|
| which protocol | protocol: |
transport: |
| where | host:, port:, codec: |
host:, port:, codec: |
| CA to trust | ca_file: |
ca: |
| verify the peer | verify_peer: |
verify_peer: |
| own certificate | cert:, key: |
— (no client counterpart) |
So ca is the client's spelling of the daemon's ca_file, and cert/key
are the daemon's server certificate — a client config has no equivalent. The
validator names the key it expected, so a mix-up fails at config set rather
than at connect time.
For same-host use you do not need this at all: the daemon writes a working
client/config.yaml and client/auto.json into the session on every boot.
The two documents are kept separate on purpose: the daemon never reads
client/, and the client never readsdaemon/. Only the files above are YAML — everything the daemon and the modules own (state.json,tokens.json, the token files, the downloader's catalog config) stays JSON.
Client Commands
Commands are grouped by what they act on. ls is the list verb throughout,
show the detail verb.
# Daemon
logosctl daemon start [--detach] # start the runtime
logosctl daemon stop # graceful shutdown
logosctl daemon status # health, uptime, module summary
logosctl daemon config set FILE # install/replace the daemon config
logosctl daemon config show # print it, and where it lives
# Client dial settings (only needed to reach a daemon on another host)
logosctl client config set FILE
logosctl client config show
# Modules — what is running right now
logosctl module ls [--loaded] # list known / loaded modules
logosctl module show NAME # methods, events, deps, crash detail
logosctl module load NAME # + dependencies, always (no opt-out)
logosctl module unload NAME # + dependents (--no-dependents to opt out)
logosctl module reload NAME
logosctl module stats # per-module CPU / memory
# Calling and watching
logosctl call MODULE METHOD [args...]
logosctl watch MODULE [--event NAME]
# Packages — what is on disk
# install/upgrade share one option set:
# --file X.lgx --dir D --version V --root-hash H --catalog C
# -y|--yes --dry-run --no-deps --no-dependents
# remove takes names only: -y --dry-run --no-dependents
logosctl package install NAME|FILE.lgx ... # a path installs from disk
logosctl package install --dir D # every .lgx in a directory
logosctl package upgrade NAME|FILE.lgx
logosctl package remove NAME # + dependents by default
logosctl package ls [--type core|ui]
logosctl package show NAME|FILE.lgx # installed detail, or inspect an .lgx
logosctl package deps NAME [-r|--recursive] [--reverse]
logosctl package search [QUERY] [--category C] [--catalog C]
logosctl package download NAME [--version V] [--root-hash H] [--catalog C] [-o|--output DIR]
# Catalogs
logosctl catalog ls
logosctl catalog add URL | remove URL | enable URL | disable URL
logosctl catalog refresh
# Trusted signing keys (per session)
logosctl key ls
logosctl key add NAME --did DID [--display-name N] [--url U]
logosctl key remove NAME
# Auth tokens (offline; operates on the session dir)
logosctl token issue --name N [--expires D] [--replace] [--local-only]
logosctl token ls
logosctl token revoke NAME
Shorthands exist for the things you type most: status, stop, call,
watch, stats, install, and search work at the top level. list is
accepted wherever ls is, info wherever show is, and uninstall for
package remove.
install and search are the only package commands aliased to the top level,
because they are the only ones with no runtime-module meaning — ls, show
and remove would each be ambiguous between a package and a loaded module.
Two defaults are worth stating plainly, because they are the opposite of what some tools do:
installdoes not load. It puts files on disk;module loadactivates them. Staging a package without starting it is a thing you may well want.removetakes dependents with it, andunloadtoo. Leaving a module running against something that no longer exists is the more surprising outcome.--no-dependentsopts out, acting on the named package or module alone and leaving its dependents in place.module loadalways resolves dependencies. There is no--no-depson it — that flag exists only onpackage install/package upgrade, which are about files on disk, not about what is running.- An argument ending in
.lgxis a path, not a name.installandupgraderead it as a file on disk and skip the catalog entirely, so a local package installs with no catalog configured and no network.--filesays so explicitly and--dirtakes every.lgxin a directory. Catalog names and local files cannot be mixed in one command — they resolve by different rules, so run them separately.
Installing does not require a daemon restart: the daemon re-scans afterwards, and only modules that were already running are stopped and restarted.
Argument typing
Each positional argument to call is turned into a JSON value using the first
rule that matches, so scalars stay ergonomic while lists, maps, and literal
strings are all expressible:
| Argument form | Becomes | Example |
|---|---|---|
json:<value> |
the value parsed as JSON (list / map / number / any nested value) | json:[1,2,3], json:{"k":"v"} |
json:@<file> |
the file's contents parsed as JSON | json:@payload.json |
str:<text> |
<text> verbatim as a string — no parsing, no coercion |
str:json:x → "json:x", str:42 → "42" |
@<file> |
the file's raw contents as a string | @config.json |
true / false |
a boolean | true |
| a whole number | an integer | 42 |
| a decimal number | a double | 3.14 |
| anything else | a string | hello |
json: and str: are the two explicit escapes, mirroring the convention used
by jq (--arg / --argjson) and HTTPie (= / :=): the default path never
guesses a container, json: opts into parsing, and str: forces a literal
string for any value the default rules would otherwise reinterpret (a
number-like string, or one that itself starts with json: / str: / @).
Binary (bstr) arguments. JSON has no native byte type, so bytes use the
canonical tagged encoding — a JSON object {"_bytes": "<base64url, unpadded>"}.
Pass it like any other JSON value with json::
# base64url("hello") == "aGVsbG8"
logosctl call blobstore put 'json:{"_bytes":"aGVsbG8"}'
logosctl call blobstore put 'json:@blob.json' # {"_bytes":"..."} from a file
Exit Codes
| Code | Meaning |
|---|---|
0 |
Success |
1 |
General error / daemon not running (for status) |
2 |
No daemon running |
3 |
Module error (not found, load/unload failed) |
4 |
Method error (not found, call failed, timeout) |
Packages
logosctl installs and manages packages itself — it bundles package_manager and
package_downloader, the same modules Basecamp uses, so both frontends drive an
identical surface. All package commands need a running daemon, because that is
where those modules live.
logosctl catalog ls # configured catalogs
logosctl catalog add <url> # add one; also remove/enable/disable
logosctl catalog refresh # re-fetch every enabled catalog
logosctl search storage # search the merged catalog
logosctl install storage_module --dry-run # show what would change
logosctl install storage_module -y
logosctl package ls # what is installed in this session
logosctl package show storage_module # or a path to an .lgx to inspect it
logosctl package deps storage_module -r
logosctl package upgrade storage_module -y
logosctl package remove storage_module -y
A package you already have on disk needs none of that — no catalog, no
network. Give install the path:
logosctl install ./storage_module.lgx -y # or: --file ./storage_module.lgx
logosctl install --dir ./downloads -y # every .lgx in a directory
logosctl package show ./storage_module.lgx # inspect one without installing
install puts files on disk; it does not load anything. Loading is a
separate, explicit act:
logosctl install storage_module -y
logosctl module load storage_module
The daemon re-scans after an install, so a freshly installed module is loadable
immediately — no restart. What install does restart is anything that was
already running and had to be stopped to make way; nothing else is touched.
Dependencies are handled by default in the direction that avoids breakage:
install/upgrade pull dependencies in (--no-deps opts out), and remove
takes dependents with it (--no-dependents opts out). --dry-run prints the
full change table plus which running modules will be stopped, and without -y
you are asked to confirm. With no terminal and no -y the operation is refused
rather than assumed — a script that forgot --yes should fail loudly.
Package signatures are verified against the session's own keyring:
logosctl key ls
logosctl key add acme --did did:jwk:... --display-name "Acme"
logosctl key remove acme
Because the keyring lives inside the session, two sessions can hold different trust assumptions — and this is deliberately not shared with Basecamp's keyring, so a key trusted in one is not automatically trusted in the other.
Agent / Script Example
# Start daemon (--detach returns only once it is accepting commands,
# so no `&` and no sleep are needed)
logosctl daemon start --detach
# Preflight: verify daemon is running
logosctl daemon status --json | jq -e '.daemon.status == "running"' > /dev/null
# Load modules
logosctl module load chat --json
# Discover available methods (with their documentation)
logosctl module show chat --json | jq '.methods[] | {name, description}'
# Discover the events a module emits (with their documentation)
logosctl module show chat --json | jq '.events[] | {name, description}'
# Call a method
logosctl call chat send_message "hello from script" --json
# Auto-reload any crashed modules
logosctl module ls --json | jq -r '.[] | select(.status == "crashed") | .name' | while read mod; do
logosctl module reload "$mod" --json
done
# Stream events to a log file
logosctl watch chat --event chat-message --json >> events.log &
Events Example
Modules can emit events that you can listen to in real time. Use watch to subscribe and call to trigger:
# Start the daemon (the modules it scans come from the session's
# modules/ directory and any modules_dirs in daemon/config.yaml)
logosctl daemon start --detach
# Load the module
logosctl module load test_basic_module
# Start watching for events in the background, writing to a file
logosctl watch test_basic_module --event testEvent > events.txt &
WATCH_PID=$!
# Trigger the event from another call
logosctl call test_basic_module emitTestEvent "hello world"
# Check the captured event
cat events.txt
# Clean up
kill $WATCH_PID
logosctl daemon stop
Quick start: load modules and call methods
The daemon starts clean (it scans the module directories but loads nothing
on its own). Load modules with module load — transitive dependencies are
resolved automatically — then call methods:
# Start a clean daemon on this session's modules
logosctl daemon start --detach
# Load modules (deps resolved automatically)
logosctl module load waku
logosctl module load chat
# Call methods (positional args — see "Argument typing" above)
logosctl call chat send_message hello
logosctl call storage init config 42 true
logosctl call storage loadConfig @config.json # @file → raw file contents
logosctl call storage setTags 'json:["a","b"]' # json: → parsed list/map/value
logosctl call storage setLabel 'str:42' # str: → literal string "42"
# A throwaway session, isolated from ~/.logosctl
logosctl --config-dir /tmp/test-session daemon start --detach
# Stop the daemon when done
logosctl daemon stop
Daemon startup options:
-D Start the daemon (same as `daemon start`)
-d, --detach Fork and return once the daemon is accepting commands
--config-dir DIR Which session to run
Everything else — module directories, persistence, transports, the access
policy — is configuration, and lives in daemon/config.yaml.
These global flags work before or after any subcommand:
-h, --help Show help
--version Print the version
-v, --verbose Show debug logs
-j, --json Force JSON output
--no-json,--human Force human-readable output even when piped
-q, --quiet Suppress non-essential output
--config-dir DIR Which session to act on (also LOGOSCTL_CONFIG_DIR)
Access policy
The access_policy key declares, per target module, which caller modules may
invoke it. Unlike every other key in this document, its value is a JSON
document carried as a string — the daemon hands the text straight to the
runtime rather than interpreting it, so it is not modelled as YAML. A YAML
block scalar (|) is the readable way to write it:
access_policy: |
{
"version": 1,
"mode": "enforce",
"restrictions": {
"package_manager": { "allowedCallers": ["package_manager_ui"] },
"package_downloader": { "allowedCallers": ["package_manager_ui"] }
}
}
Do not write it as a YAML mapping.
access_policy:followed by an indentedversion: 1/restrictions:block is read as an object where a string is expected.daemon config setreports it by name (access_policy: expected a string, but got a mapping.) and writes nothing. Quote the JSON, or use the|block above.
The string is handed to the runtime (via logos_core_set_access_policy)
before any module is loaded.
Note: the value must be a full policy document. The
enforceshorthand is alogoscoreflag 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 pluslogosctl call ...as shown above.