From 5ba215b36c92daf6826687ba3541c814dea52bff Mon Sep 17 00:00:00 2001 From: WHC Date: Sun, 11 Oct 2026 11:42:04 +0800 Subject: [PATCH] v0.8.7: tool capability metadata, in-process engine, Qt app, Android engine The engine can be embedded now instead of only driven from a terminal: the same objects link into a Qt front end (app/) that owns a cst_agent_t inside its own process -- no port, no token, no second process -- and that front end now runs on the desktop and, engine-side, on the phone. Capability metadata (include/cst_tools.h, src/tools/registry.c): - cst_tool_def_t gains perm_name, platforms and available (a probe that returns a reason), so a permission is addressable by name and a tool can say why it cannot run here. - cst_platform_name(), cst_tool_platform_ok(), cst_tool_available(), cst_tool_meta_reload(), cst_tool_perm_name() and CST_PLAT_DESKTOP[]. - cst_tool_register() may take over a built-in name that is not runnable on this platform, so a front end replaces it with its own implementation. - the registry entries carry their class (delete_file -> "delete"), the desktop clipboard tools carry a clipboard probe; find/active/run gate on it. - /api/settings.tools[] exposes perm_name, platforms, available, why and the declared parameters. Engine as a library (Makefile, CMakeLists.txt): - libcstcore.a from the CORE_OBJ set, and a CMake cstcore target shared by the CLI and by app/ (-DCST_BUILD_APP=ON): the engine is compiled once. - Android branch for the vendored sashidown: a per-ABI archive built with the NDK's aarch64-linux-android24-clang wrapper, because CMake's compiler is plain clang and keeps --target/--sysroot in its own variables. Qt front end (app/, new): - AgentCore wraps one cst_agent_t: turns run on a worker thread, every cst_agent_hooks_t callback becomes a Qt signal, and the two hooks that need an answer (on_permission, on_ask_user) block the worker until the GUI replies; mode, approval and plan mode go through the engine's config. - ToolProvider hands the front end's tools to the engine (owner "app:*"); the Linux one is notification_send with a probe for notify-send/D-Bus. - Main.qml: transcript, tool blocks, consent dialog (Deny/Once/Always -> CST_PERM_*), ask_user, mode switch, Esc to cancel; --selftest stays headless and runs the embedding checks without QML. Android (scripts/, docs/android.md, new): - scripts/build_third_party_android.sh builds static mbedTLS + libcurl for an ABI (CA path /system/etc/security/cacerts); build_android.sh builds cst-agent, libcstcore.a and the test binaries for the phone, can push them and run the whole suite there. - sandbox_exec.c keeps the plain exec path on Android: the seccomp supervisor compiled in and the probe reported "seccomp", but writes outside the workspace were not denied on the device -- claiming a sandbox that does not hold is worse than reporting none. - tests/test_diff.c compares layouts only against real GNU diffutils: toybox's diff prints the same hunk header with its own hunk/context rules. Tests: test_tool_meta (metadata, platform gate, probe reasons, takeover of a built-in name) and test_embed (in-process embedding contract) are new; the existing registry/llm_body assertions now follow availability. 38 test binaries pass on the host, 36/36 on arm64-v8a Android 16, cst-app-selftest passes in both the standalone app/ build and the repo build. Version 0.8.7. --- .gitignore | 6 + CMakeLists.txt | 66 +++++- Makefile | 15 +- app/CMakeLists.txt | 102 ++++++++++ app/README.md | 119 +++++++++++ app/qml/Main.qml | 229 +++++++++++++++++++++ app/src/core/AgentCore.cpp | 283 ++++++++++++++++++++++++++ app/src/core/AgentCore.h | 129 ++++++++++++ app/src/core/ToolProvider.cpp | 40 ++++ app/src/core/ToolProvider.h | 59 ++++++ app/src/main.cpp | 155 ++++++++++++++ app/src/platform/linux/NotifyTool.cpp | 109 ++++++++++ app/tools/selftest.c | 151 ++++++++++++++ docs/android.md | 73 +++++++ include/cst_clipboard.h | 4 + include/cst_tools.h | 34 +++- include/cst_version.h | 2 +- scripts/build_android.sh | 107 ++++++++++ scripts/build_third_party_android.sh | 137 +++++++++++++ src/clipboard.c | 45 ++++ src/sandbox_exec.c | 8 +- src/tools/registry.c | 174 ++++++++++++++-- src/webui.c | 23 +++ tests/test_diff.c | 16 ++ tests/test_embed.c | 189 +++++++++++++++++ tests/test_llm_body.c | 7 +- tests/test_registry.c | 8 +- tests/test_tool_meta.c | 196 ++++++++++++++++++ 28 files changed, 2452 insertions(+), 34 deletions(-) create mode 100644 app/CMakeLists.txt create mode 100644 app/README.md create mode 100644 app/qml/Main.qml create mode 100644 app/src/core/AgentCore.cpp create mode 100644 app/src/core/AgentCore.h create mode 100644 app/src/core/ToolProvider.cpp create mode 100644 app/src/core/ToolProvider.h create mode 100644 app/src/main.cpp create mode 100644 app/src/platform/linux/NotifyTool.cpp create mode 100644 app/tools/selftest.c create mode 100644 docs/android.md create mode 100755 scripts/build_android.sh create mode 100755 scripts/build_third_party_android.sh create mode 100644 tests/test_embed.c create mode 100644 tests/test_tool_meta.c diff --git a/.gitignore b/.gitignore index 3e2fb55..12fb78b 100644 --- a/.gitignore +++ b/.gitignore @@ -28,10 +28,16 @@ third_party/busybox-src/ third_party/prefix-mingw/ third_party/prefix-linux-*/ third_party/build-linux-*/ +third_party/prefix-android-*/ +third_party/build-android-*/ third_party/src/ vendor/sashidown/*.o vendor/sashidown/src/*.o vendor/sashidown/libsashidown.a +vendor/sashidown/libsashidown-*.a vendor/sashidown/libsashidown-mingw.a .gen/ .build-tmp/ + +# the engine as a static library (app/ links it; built by `make`) +libcstcore.a diff --git a/CMakeLists.txt b/CMakeLists.txt index 484e0db..8fef65b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,7 @@ set(CMAKE_C_STANDARD_REQUIRED ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" ON) +option(CST_BUILD_APP "Build the Qt front end in app/ (in-process engine, no port)" OFF) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) @@ -64,6 +65,29 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Windows") COMMAND ${CST_MAKE} -C "${SD_DIR}" clean COMMENT "Building libsashidown for MinGW" VERBATIM) +elseif(CMAKE_SYSTEM_NAME STREQUAL "Android") + # The host archive has to stay host-built (the CLI, the tests and the + # Makefile link it), so the NDK build keeps its own copy, one per ABI. + # CMake's compiler is plain clang (the --target/--sysroot flags live in its + # own variables), so the vendor make gets the per-target wrapper instead: + # aarch64-linux-android24-clang carries both. + get_filename_component(_SD_CC_BIN "${CMAKE_C_COMPILER}" DIRECTORY) + string(REPLACE "-none" "" _SD_TRIPLE "${CMAKE_C_COMPILER_TARGET}") + set(_SD_CC "${_SD_CC_BIN}/${_SD_TRIPLE}-clang") + if(NOT EXISTS "${_SD_CC}") + set(_SD_CC "${CMAKE_C_COMPILER}") + endif() + set(SASHIDOWN_A "${SD_DIR}/libsashidown-${ANDROID_ABI}.a") + add_custom_command( + OUTPUT "${SASHIDOWN_A}" + COMMAND ${CST_MAKE} -C "${SD_DIR}" clean + COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a + CC=${_SD_CC} AR=${CMAKE_AR} RANLIB=${CMAKE_RANLIB} + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}" + COMMAND ${CST_MAKE} -C "${SD_DIR}" clean + COMMENT "Building libsashidown for Android (${ANDROID_ABI})" + VERBATIM) else() set(SASHIDOWN_A "${SD_DIR}/libsashidown.a") add_custom_command( @@ -181,7 +205,34 @@ if(WIN32) set_source_files_properties(${_CST_TRE_SOURCES} PROPERTIES COMPILE_OPTIONS "-w") endif() -set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) +# --- the engine as a library --------------------------------------------- +# cstcore is the core without main()/TUI: the CLI links it and the Qt front +# end in app/ (CST_BUILD_APP) embeds the very same objects in-process, so the +# engine is compiled once per build instead of once per front end. +add_library(cstcore STATIC ${CST_CORE_SOURCES}) +add_dependencies(cstcore sashidown) +target_include_directories(cstcore PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${SD_DIR}/include" + "${CJSON_DIR}" +) +target_compile_definitions(cstcore PRIVATE _GNU_SOURCE) +if(WIN32) + target_compile_definitions(cstcore PRIVATE _WIN32_WINNT=0x0A00) + target_link_libraries(cstcore PUBLIC + "${SASHIDOWN_A}" CURL::libcurl Threads::Threads + ws2_32 crypt32 secur32 bcrypt advapi32 user32) +else() + target_compile_definitions(cstcore PRIVATE _XOPEN_SOURCE_EXTENDED) + target_link_libraries(cstcore PUBLIC + "${SASHIDOWN_A}" CURL::libcurl Threads::Threads m) +endif() + +if(CST_BUILD_APP) + add_subdirectory(app) +endif() + +set(CST_SOURCES src/main.c) if(CST_BUILD_TUI) list(APPEND CST_SOURCES src/textsel.c src/tui.c) @@ -204,14 +255,8 @@ if(NOT WIN32) target_compile_definitions(cst-agent PRIVATE _XOPEN_SOURCE_EXTENDED) endif() -target_link_libraries(cst-agent PRIVATE - "${SASHIDOWN_A}" - CURL::libcurl - Threads::Threads -) -if(NOT WIN32) - target_link_libraries(cst-agent PRIVATE m) -endif() +# cstcore carries sashidown, curl, threads and libm as usage requirements. +target_link_libraries(cst-agent PRIVATE cstcore) set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses") set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses) @@ -330,7 +375,8 @@ set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode test_undo test_config test_models test_model_registry test_sandbox test_keymap test_shell_compact test_skills test_plugins test_mcp test_subagent test_lowil - test_win_utf8 test_os_dir test_tmpdir) + test_win_utf8 test_os_dir test_tmpdir + test_tool_meta test_embed) if(CST_BUILD_WEBUI) # test_webui drives the real server; the stub has nothing to test. list(APPEND _CST_CORE_TESTS test_webui) diff --git a/Makefile b/Makefile index 11290ac..3ad94c6 100644 --- a/Makefile +++ b/Makefile @@ -120,6 +120,10 @@ CORE_SRC = \ SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c OBJ = $(SRC:.c=.o) +# The core as a static library: what the Qt app embeds in-process (app/), so +# the GUI links the same engine the CLI/TUI/WebUI run. No TUI, no main. +CORE_OBJ = $(CORE_SRC:.c=.o) +CST_CORE_LIB = libcstcore.a HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \ .gen/models_builtin.inc .gen/web_assets.inc @@ -141,6 +145,8 @@ TESTS = \ tests/test_diff \ tests/test_tools_fs \ tests/test_registry \ + tests/test_tool_meta \ + tests/test_embed \ tests/test_undo \ tests/test_config \ tests/test_models \ @@ -171,7 +177,11 @@ TEST_OS_SRC = src/os_compat.c src/os_win.c src/util.c .PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall -all: sashidown-lib cst-agent +all: sashidown-lib cst-agent $(CST_CORE_LIB) + +$(CST_CORE_LIB): $(CORE_OBJ) FORCE + rm -f $@ + $(AR) rcs $@ $(CORE_OBJ) cmake-gcc: cmake -B build-gcc -DCMAKE_C_COMPILER=gcc -DCST_BUILD_TUI=ON @@ -257,7 +267,8 @@ demo: cst-agent ./cst-agent --demo-ctl --workspace . clean: - rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc + rm -f $(OBJ) cst-agent $(CST_CORE_LIB) $(TESTS) .gen/models_builtin.inc \ + .gen/web_assets.inc install: cst-agent install -d "$(DESTDIR)$(PREFIX)/bin" diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt new file mode 100644 index 0000000..b023752 --- /dev/null +++ b/app/CMakeLists.txt @@ -0,0 +1,102 @@ +cmake_minimum_required(VERSION 3.16) +project(cst-agent-app LANGUAGES C CXX VERSION 0.1.0) + +# The Qt front end (app/) embeds the C engine in-process: it links libcstcore +# and never opens a port. Two targets: +# +# cst-app-selftest pure C smoke test of the embedding path (no Qt), so the +# C side is still guarded on machines without Qt Quick +# cst-agent-app the GUI (Qt 6 Quick + QML), built when the Qt modules +# are there +# +# Standalone build (builds the engine from ../ too): +# cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug && cmake --build build-app -j +# Inside the repo build (the parent defines the cstcore target, shared with the CLI): +# cmake -B build -DCST_BUILD_APP=ON && cmake --build build --target cst-agent-app +# +# Extra QML import paths, e.g. a Kirigami installed away from the Qt prefix: +# -DCST_APP_QML_IMPORT=... (added to QML2_IMPORT_PATH / the app's runtime) + +option(CST_APP_GUI "build the Qt GUI (needs Qt6 Quick, Qml, QuickControls2)" ON) +option(CST_APP_SELFTEST "build the C embedding smoke test" ON) + +# --- the engine ---------------------------------------------------------- +# Used as a subproject (../CMakeLists.txt already defined cstcore, engine +# compiled once for CLI and app) or built here from the sources next door. +if(NOT TARGET cstcore) + set(CST_APP_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/..") + include_directories("${CST_APP_ROOT}/include" "${CST_APP_ROOT}/vendor/cjson") + file(GLOB CST_APP_CORE_SRC + "${CST_APP_ROOT}/src/*.c" + "${CST_APP_ROOT}/src/tools/*.c" + "${CST_APP_ROOT}/vendor/cjson/cJSON.c") + list(REMOVE_ITEM CST_APP_CORE_SRC + "${CST_APP_ROOT}/src/main.c" + "${CST_APP_ROOT}/src/tui.c" + "${CST_APP_ROOT}/src/tui_stub.c" + "${CST_APP_ROOT}/src/textsel.c") + # The HTTP front end is not part of an in-process app: no server, no port. + list(REMOVE_ITEM CST_APP_CORE_SRC + "${CST_APP_ROOT}/src/net.c" + "${CST_APP_ROOT}/src/http_srv.c" + "${CST_APP_ROOT}/src/webui.c" + "${CST_APP_ROOT}/src/web_md.c") + if(EXISTS "${CST_APP_ROOT}/.gen/models_builtin.inc") + include_directories("${CST_APP_ROOT}/.gen") + endif() + add_library(cstcore STATIC ${CST_APP_CORE_SRC}) + target_compile_definitions(cstcore PRIVATE CST_NO_WEBUI=1) + find_package(CURL REQUIRED) + find_package(Threads REQUIRED) + target_link_libraries(cstcore PUBLIC ${CURL_LIBRARIES} Threads::Threads m) + message(STATUS "app: building the engine from ${CST_APP_ROOT}") +else() + message(STATUS "app: linking the cstcore target of the parent build") +endif() + +# --- the C smoke test ---------------------------------------------------- +if(CST_APP_SELFTEST) + add_executable(cst-app-selftest tools/selftest.c) + target_link_libraries(cst-app-selftest PRIVATE cstcore) + enable_testing() + add_test(NAME cst-app-selftest COMMAND cst-app-selftest) +endif() + +# --- the GUI ------------------------------------------------------------- +if(CST_APP_GUI) + find_package(Qt6 6.4 COMPONENTS Core Gui Qml Quick QuickControls2 QUIET) + if(NOT Qt6_FOUND) + message(WARNING + "app: Qt 6 Quick/Qml not found, skipping cst-agent-app " + "(the C selftest still builds; install e.g. qt6.qtdeclarative / " + "qt6.qtquickcontrols2 and reconfigure)") + else() + qt_standard_project_setup() + # Qt 6.5+ policies: keep the module resource at :/qt/qml// (what + # main.cpp loads) and let CMake generate the qmldir of qml/ for us. + qt_policy(SET QTP0001 NEW) + qt_policy(SET QTP0004 NEW) + qt_add_executable(cst-agent-app + src/main.cpp + src/core/AgentCore.cpp + src/core/ToolProvider.cpp + src/platform/linux/NotifyTool.cpp) + target_include_directories(cst-agent-app PRIVATE + src "${CMAKE_CURRENT_SOURCE_DIR}/../include") + target_compile_definitions(cst-agent-app PRIVATE CST_APP_VERSION="${PROJECT_VERSION}") + if(CST_APP_QML_IMPORT) + target_compile_definitions(cst-agent-app PRIVATE + CST_APP_QML_IMPORT="${CST_APP_QML_IMPORT}") + endif() + target_link_libraries(cst-agent-app PRIVATE + Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::QuickControls2 cstcore) + if(WIN32) + set_target_properties(cst-agent-app PROPERTIES WIN32_EXECUTABLE TRUE) + endif() + + qt_add_qml_module(cst-agent-app + URI CstAgent + VERSION 1.0 + QML_FILES qml/Main.qml) + endif() +endif() diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..735c693 --- /dev/null +++ b/app/README.md @@ -0,0 +1,119 @@ +# app/ — the Qt front end + +CST-agent's GUI: **Qt 6 + QML + the C engine, in one process**. No HTTP +server, no port, no token, no second process — the app creates a `cst_agent_t` +directly and drives it through `cst_agent.h`. + +``` +app/ the GUI + src/main.cpp entry point, --workspace / --selftest + src/core/AgentCore.* QObject around cst_agent_t: turns, hooks, consent + src/core/ToolProvider.h how the front end hands tools to the engine + src/platform// platform tools the app implements itself + qml/Main.qml the window (chat, tool runs, consent dialog) + tools/selftest.c C smoke test of the embedding path (no Qt) +``` + +## Build + +```sh +# engine + GUI + C smoke test +cmake -S app -B build-app -DCMAKE_BUILD_TYPE=Debug +cmake --build build-app -j +QT_QPA_PLATFORM=offscreen ctest --test-dir build-app # cst-app-selftest + +# the app alone +./build-app/cst-agent-app --workspace ~/notes +./build-app/cst-agent-app --selftest # same checks as the C test +``` + +The standalone build is the supported one: `app/CMakeLists.txt` uses the +`cstcore` target when the parent project defines one, and otherwise builds +the engine itself from `../src` (TUI, `main.c` and the HTTP front end +excluded). Wiring it into the repository's `CMakeLists.txt` as +`-DCST_APP=ON` is on the list below. + +Needs Qt 6.4+ with **Quick / Qml / QuickControls2** (`qt6.qtdeclarative`, +`qt6.qtquickcontrols2` on Nix; the online installer's "Qt 6.x Desktop" on +Windows/macOS). Without them the configure step warns and builds only the C +smoke test — that is deliberate: the embedding path stays verifiable +everywhere. Kirigami is not required yet; `qml/Main.qml` uses Qt Quick +Controls and keeps the layout in Kirigami's shape (single column, cards, +navigation drawer) so swapping in `org.kde.kirigami` later is a QML-only +change. Point the app at a Kirigami outside the Qt prefix with +`-DCST_APP_QML_IMPORT=/path/to/qml`. + +## Why in one process + +The engine is a library, not a daemon: `libcstcore.a` (Makefile) / the +`cstcore` target (CMake). The app owns one `cst_agent_t` per window, so +there is no protocol to version, no socket to authenticate and no second +process to babysit — and the CLI, TUI and WebUI keep working off the same +source. + +- `AgentCore::send()` runs `cst_agent_run_turn_ex()` on a worker thread; + the `cst_agent_hooks_t` callbacks turn into Qt signals (assistant deltas, + tool events, tool results, todo, turn end). +- Consent is a blocking hook: the worker waits on a `QWaitCondition`, the + GUI answers, the turn goes on. `CST_PERM_DENY / ONCE / ALWAYS_TOOL / + ALWAYS_PREFIX` map 1:1 onto the buttons, and `ALWAYS_*` still lands in + `.agent/permissions.json`, so a desktop decision is visible to the TUI. +- `cancel()` is `cst_agent_request_cancel()`; the turn unwinds as a normal + result, not an exception. + +## Tools the app provides + +A tool implemented in the front end is registered with `cst_tool_register()` +and the owner `app:`, exactly like a plugin or MCP server would: + +```c +cst_tool_def_t def = {0}; +def.name = "notification_send"; +def.desc = "Send a system notification."; +def.perm = CST_TP_ASK; /* class: drives the consent dialog */ +def.perm_name = "notify"; /* policy: what the user is really granting */ +def.platforms = ANDROID_ONLY; /* NULL = everywhere */ +def.available = probe; /* 0 / -1 with a reason; cached */ +def.run_ex = my_run; +def.ud = my_state; +def.owner = "app:android"; +cst_tool_register(&def); +``` + +- `platforms` + `available` decide *whether* the tool exists here: an + Android-only tool never reaches the prompt or `tools[]` on Linux, and + `cst_tool_run()` refuses it with the reason if a model asks anyway. +- A front-end tool may take over the **name** of a built-in that cannot run + on this host (the Android clipboard replacing the desktop `xclip` one); + it may not shadow one that works. +- `cst_tool_unregister_owner("app:android")` drops them again — do it when a + platform service goes away, then `cst_tool_meta_reload()`. +- `setenv("CST_PLATFORM", ...)` overrides the detected host name (tests, a + launcher that knows better). + +Note `run_ex` runs on the worker thread while the turn is going: touch the +GUI only through queued signals. + +## Platform notes + +| Platform | How the engine gets built | Platform tools | +|---|---|---| +| Linux | `cmake -S app -B build-app` (libcurl via pkg-config) | clipboard (shell helpers), notification (`notify-send` / D-Bus) | +| Windows | Qt's MinGW kit, `cmake -G Ninja -DCMAKE_PREFIX_PATH=/lib/cmake`; the engine needs libcurl for Windows (see `docs/README-windows.md`) | notification via the WinRT/tray API, clipboard through the core's Win32 path | +| Android | engine compiled with the NDK inside the APK (`-DCST_TUI=0`, `CST_NO_WEBUI=1`); the app talks to Android APIs through Qt (JNI) | notification (Android), share sheet, clipboard, file picker | +| macOS | Qt from the installer, libcurl from the system | notification (`osascript`), clipboard (`pbpaste`/`pbcopy`) | + +Android's long-term shape: the core lives in the APK (no Termux needed), the +platform tools are Kotlin/JNI behind the same `cst_tool_register()` contract. +The build flag that keeps Unix helpers out of the picture is +`CST_PLATFORM=android` (or the `__ANDROID__` detection inside the engine). + +## Status + +- [x] engine as a library (`libcstcore.a` / `cstcore`), in-process embed API +- [x] tool metadata: `perm_name`, `platforms`, `available`, shadowing rule +- [x] `AgentCore` (turns, cancel, consent, todo) and the C smoke test +- [ ] QML window against a real provider (needs Qt Quick) +- [ ] session list / switch, attachments, diff viewer, settings page +- [ ] Kirigami (Breeze look), tray, global shortcuts +- [ ] Android APK (engine in-process, JNI platform tools) diff --git a/app/qml/Main.qml b/app/qml/Main.qml new file mode 100644 index 0000000..dff61e5 --- /dev/null +++ b/app/qml/Main.qml @@ -0,0 +1,229 @@ +// The app window: chat on the left, tool runs and consent on top of it. +// +// Qt Quick Controls only, laid out the way Kirigami expects (single column, +// cards, drawer for navigation) so that swapping `import QtQuick.Controls` +// for `import org.kde.kirigami as Kirigami` and Page/Card wrappers is a QML +// change, not a rewrite. + +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ApplicationWindow { + id: win + width: 980 + height: 720 + visible: true + title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace") + + ListModel { id: transcript } + + header: ToolBar { + RowLayout { + anchors.fill: parent + Label { + text: agent.workspace.length ? agent.workspace : "(no workspace)" + elide: Text.ElideMiddle + Layout.fillWidth: true + } + BusyIndicator { + running: agent.running + visible: agent.running + implicitWidth: 24 + implicitHeight: 24 + } + ComboBox { + id: modeBox + model: ["coding", "general"] + currentIndex: agent.mode === "general" ? 1 : 0 + onActivated: agent.setMode(model[currentIndex]) + } + } + } + + // ---- consent: the engine's on_permission hook waits for this ---- + Dialog { + id: consent + anchors.centerIn: parent + width: Math.min(720, win.width - 40) + modal: true + property string tool: "" + property string detail: "" + property string preview: "" + title: "Allow " + tool + "?" + contentItem: ColumnLayout { + Label { text: consent.detail; wrapMode: Text.Wrap; Layout.fillWidth: true } + ScrollView { + Layout.fillWidth: true + Layout.preferredHeight: Math.min(300, previewText.implicitHeight + 16) + visible: consent.preview.length > 0 + TextArea { + id: previewText + text: consent.preview + readOnly: true + font.family: "monospace" + wrapMode: TextArea.NoWrap + } + } + } + footer: DialogButtonBox { + Button { text: "Deny"; DialogButtonBox.buttonRole: DialogButtonBox.RejectRole } + Button { text: "Once"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole } + Button { text: "Always"; DialogButtonBox.buttonRole: DialogButtonBox.ActionRole } + onRejected: { agent.answerPermission(0); consent.close() } + onAccepted: { agent.answerPermission(1); consent.close() } + onClicked: function (button) { + if (button.text === "Always") { agent.answerPermission(2); consent.close() } + } + } + } + + // ---- ask_user ---- + Dialog { + id: question + anchors.centerIn: parent + width: Math.min(600, win.width - 40) + modal: true + property string text: "" + property string options: "" + title: "The agent asks" + contentItem: ColumnLayout { + Label { text: question.text; wrapMode: Text.Wrap; Layout.fillWidth: true } + Label { + visible: question.options.length > 0 + text: question.options + wrapMode: Text.Wrap + Layout.fillWidth: true + opacity: 0.7 + } + TextField { id: answerField; Layout.fillWidth: true; placeholderText: "your answer" } + } + footer: DialogButtonBox { + Button { text: "Send"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole } + onAccepted: { agent.answerQuestion(answerField.text); question.close() } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + ListView { + id: chat + Layout.fillWidth: true + Layout.fillHeight: true + model: transcript + clip: true + spacing: 8 + delegate: Frame { + width: chat.width + background: Rectangle { + color: model.role === "user" ? "#2b579a22" : "#00000000" + border.color: "#88888833" + radius: 6 + } + ColumnLayout { + width: parent.width + spacing: 2 + Label { + text: model.role === "user" ? "You" : "Agent" + font.bold: true + opacity: 0.7 + } + TextEdit { + text: model.text + readOnly: true + wrapMode: TextEdit.Wrap + selectByMouse: true + Layout.fillWidth: true + } + } + } + onCountChanged: positionViewAtEnd() + } + + RowLayout { + Layout.fillWidth: true + TextArea { + id: composer + Layout.fillWidth: true + Layout.preferredHeight: 90 + placeholderText: agent.running ? "… working (Esc cancels)" + : "Ask something (Enter sends, Shift+Enter for a new line)" + wrapMode: TextArea.Wrap + enabled: !agent.running + Keys.onPressed: function (ev) { + if (ev.key === Qt.Key_Return && !(ev.modifiers & Qt.ShiftModifier)) { + sendButton.clicked() + ev.accepted = true + } + } + } + ColumnLayout { + Button { + id: sendButton + text: "Send" + enabled: !agent.running && composer.text.trim().length > 0 + onClicked: { + transcript.append({ role: "user", text: composer.text }) + transcript.append({ role: "agent", text: "" }) + agent.send(composer.text) + composer.text = "" + } + } + Button { + text: "Stop" + enabled: agent.running + onClicked: agent.cancel() + } + } + } + } + + Shortcut { sequence: "Escape"; onActivated: agent.cancel() } + + // ---- engine -> UI ---- + function appendToAgent(text) { + if (transcript.count === 0) + transcript.append({ role: "agent", text: "" }) + const last = transcript.count - 1 + transcript.setProperty(last, "text", transcript.get(last).text + text) + } + + Connections { + target: agent + function onAssistantDelta(text) { appendToAgent(text) } + function onStatusChanged(text) { + transcript.append({ role: "agent", text: "· " + text }) + } + function onToolEvent(phase, name, detail) { + transcript.append({ + role: "agent", + text: "[" + name + " " + phase + (detail.length ? ": " + detail : "") + "]" + }) + } + function onToolResult(name, exitCode, output) { + transcript.append({ + role: "agent", + text: "[" + name + " exit=" + exitCode + "]\n" + output + }) + } + function onPermissionRequested(tool, detail, preview) { + consent.tool = tool + consent.detail = detail + consent.preview = preview + consent.open() + } + function onQuestionAsked(text, options) { + question.text = text + question.options = options + answerField.text = "" + question.open() + } + function onTurnFinished(rc, err) { + if (err.length) + transcript.append({ role: "agent", text: "error: " + err }) + } + } +} diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp new file mode 100644 index 0000000..97f813b --- /dev/null +++ b/app/src/core/AgentCore.cpp @@ -0,0 +1,283 @@ +#include "AgentCore.h" + +#include +#include +#include +#include + +/* --- the worker thread --------------------------------------------------- */ + +/* One turn: the whole cst_agent_run_turn_ex() call, on its own thread. The + * prompt is kept here so the pointer stays valid for the run. */ +class Turn : public QThread { + public: + Turn(AgentCore *owner, const QString &prompt) + : QThread(owner), m_owner(owner), m_prompt(prompt.toUtf8()) {} + + protected: + void run() override { + char err[512] = ""; + int rc = cst_agent_run_turn_ex(m_owner->m_agent, m_prompt.constData(), + &m_owner->m_hooks, err, sizeof(err)); + m_owner->finishTurn(rc, QString::fromUtf8(err)); + } + + private: + AgentCore *m_owner; + QByteArray m_prompt; +}; + +/* --- lifetime ------------------------------------------------------------ */ + +AgentCore::AgentCore(QObject *parent) : QObject(parent) { + memset(&m_cfg, 0, sizeof(m_cfg)); + memset(&m_hooks, 0, sizeof(m_hooks)); + m_hooks.on_status = hookStatus; + m_hooks.on_assistant_delta = hookAssistant; + m_hooks.on_think_delta = hookThink; + m_hooks.on_tool_event = hookToolEvent; + m_hooks.on_tool_result = hookToolResult; + m_hooks.on_todo = hookTodo; + m_hooks.on_permission = hookPermission; + m_hooks.on_ask_user = hookAskUser; + m_hooks.userdata = this; +} + +AgentCore::~AgentCore() { close(); } + +bool AgentCore::open(const QString &workspace, const QString &provider, + const QString &model, const QString &apiKey, + QString *err) { + close(); + QDir dir(workspace); + if (!dir.exists() && !dir.mkpath(".")) { + if (err) + *err = QStringLiteral("cannot create %1").arg(workspace); + return false; + } + const QByteArray ws = QFileInfo(workspace).absoluteFilePath().toUtf8(); + snprintf(m_cfg.workspace_root, sizeof(m_cfg.workspace_root), "%s", + ws.constData()); + m_workspace = QString::fromUtf8(ws); + if (!provider.isEmpty()) + snprintf(m_cfg.provider, sizeof(m_cfg.provider), "%s", + provider.toUtf8().constData()); + if (!model.isEmpty()) + snprintf(m_cfg.model, sizeof(m_cfg.model), "%s", model.toUtf8().constData()); + if (!apiKey.isEmpty()) + snprintf(m_cfg.api_key, sizeof(m_cfg.api_key), "%s", + apiKey.toUtf8().constData()); + /* The GUI answers itself: never let the engine decide behind its back. */ + m_cfg.auto_approve = 0; + m_cfg.headless = 0; + if (!m_cfg.mode[0]) + snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", CST_DEFAULT_MODE); + m_agent = cst_agent_create(&m_cfg); + if (!m_agent) { + if (err) + *err = QStringLiteral("cannot start the engine on %1").arg(m_workspace); + return false; + } + /* The session file is created by the engine; attachments live next to it. */ + emit workspaceChanged(); + return true; +} + +void AgentCore::close() { + if (!m_agent) + return; + if (m_running) { + cst_agent_request_cancel(m_agent); + answerPermission(CST_PERM_DENY); + if (m_turn) + m_turn->wait(15000); + } + m_closing = true; + cst_agent_destroy(m_agent); + m_agent = nullptr; + m_closing = false; + setRunning(false); + m_workspace.clear(); + emit workspaceChanged(); +} + +/* --- turns --------------------------------------------------------------- */ + +void AgentCore::send(const QString &prompt) { + if (!m_agent || m_running || prompt.trimmed().isEmpty()) + return; + setRunning(true); + m_turn = new Turn(this, prompt); + m_turn->start(); +} + +void AgentCore::cancel() { + if (m_agent) + cst_agent_request_cancel(m_agent); +} + +void AgentCore::finishTurn(int rc, const QString &err) { + Turn *t = static_cast(sender()); + post([this, rc, err] { + setRunning(false); + emit turnFinished(rc, err); + }); + if (t) { + t->wait(); + t->deleteLater(); + } + m_turn = nullptr; +} + +void AgentCore::setRunning(bool on) { + if (m_running == on) + return; + m_running = on; + emit runningChanged(); +} + +/* --- consent ------------------------------------------------------------- */ + +void AgentCore::answerPermission(int choice) { + QMutexLocker lock(&m_askMu); + if (!m_permPending) + return; + m_permAnswer = choice; + m_permPending = false; + m_askCv.wakeAll(); +} + +void AgentCore::answerQuestion(const QString &answer) { + QMutexLocker lock(&m_askMu); + if (!m_questionPending) + return; + m_answer = answer.toUtf8(); + m_questionPending = false; + m_askCv.wakeAll(); +} + +int AgentCore::hookPermission(const char *tool, const char *detail, + const char *preview, void *ud) { + AgentCore *self = static_cast(ud); + if (!self || self->m_closing) + return CST_PERM_DENY; + if (self->m_autoApprove) + return CST_PERM_ONCE; + QMutexLocker lock(&self->m_askMu); + self->m_permAnswer = CST_PERM_DENY; + self->m_permPending = true; + const QString t = QString::fromUtf8(tool ? tool : ""); + const QString d = QString::fromUtf8(detail ? detail : ""); + const QString p = QString::fromUtf8(preview ? preview : ""); + self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); }); + while (self->m_permPending && !self->m_closing) + self->m_askCv.wait(&self->m_askMu); + self->m_permPending = false; + return self->m_permAnswer; +} + +int AgentCore::hookAskUser(const char *question, const char *options, char *ans, + size_t ans_sz, void *ud) { + AgentCore *self = static_cast(ud); + if (!self || self->m_closing) + return -1; + if (self->m_autoApprove) + return -1; + QMutexLocker lock(&self->m_askMu); + self->m_questionPending = true; + self->m_answer.clear(); + const QString q = QString::fromUtf8(question ? question : ""); + const QString o = QString::fromUtf8(options ? options : ""); + self->post([self, q, o] { emit self->questionAsked(q, o); }); + while (self->m_questionPending && !self->m_closing) + self->m_askCv.wait(&self->m_askMu); + if (self->m_closing) + return -1; + snprintf(ans, ans_sz, "%s", self->m_answer.constData()); + return 0; +} + +/* --- settings ------------------------------------------------------------ */ + +void AgentCore::setMode(const QString &mode) { + const QByteArray m = mode.toUtf8(); + if (!cst_mode_valid(m.constData())) + return; + snprintf(m_cfg.mode, sizeof(m_cfg.mode), "%s", m.constData()); + if (m_agent) + snprintf(cst_agent_config_mut(m_agent)->mode, + sizeof(cst_agent_config_mut(m_agent)->mode), "%s", + m.constData()); + emit modeChanged(); +} + +void AgentCore::setApproval(int approval) { + m_cfg.approval = approval; + if (m_agent) + cst_agent_config_mut(m_agent)->approval = approval; +} + +void AgentCore::setPlanMode(bool on) { + m_cfg.plan_mode = on ? 1 : 0; + if (m_agent) + cst_agent_config_mut(m_agent)->plan_mode = m_cfg.plan_mode; +} + +/* --- tools the front end provides ---------------------------------------- */ + +bool AgentCore::registerTool(const cst_tool_def_t *def) { + if (!def || cst_tool_register(def) != 0) + return false; + return true; +} + +void AgentCore::unregisterOwner(const QString &owner) { + const QByteArray o = owner.toUtf8(); + if (cst_tool_unregister_owner(o.constData()) > 0) + cst_tool_meta_reload(); +} + +/* --- hook trampolines ---------------------------------------------------- */ + +void AgentCore::hookStatus(const char *msg, void *ud) { + AgentCore *self = static_cast(ud); + const QString m = QString::fromUtf8(msg ? msg : ""); + self->post([self, m] { emit self->statusChanged(m); }); +} + +void AgentCore::hookAssistant(const char *text, size_t len, void *ud) { + AgentCore *self = static_cast(ud); + const QString t = QString::fromUtf8(QByteArray(text, static_cast(len))); + self->post([self, t] { emit self->assistantDelta(t); }); +} + +void AgentCore::hookThink(const char *text, size_t len, void *ud) { + AgentCore *self = static_cast(ud); + const QString t = QString::fromUtf8(QByteArray(text, static_cast(len))); + self->post([self, t] { emit self->thinkDelta(t); }); +} + +void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call, + const char *detail, void *ud) { + AgentCore *self = static_cast(ud); + const QString p = QString::fromUtf8(phase ? phase : ""); + const QString n = QString::fromUtf8(call ? call->name : ""); + const QString d = QString::fromUtf8(detail ? detail : ""); + self->post([self, p, n, d] { emit self->toolEvent(p, n, d); }); +} + +void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out, + size_t len, int exit_code, void *ud) { + AgentCore *self = static_cast(ud); + const QString n = QString::fromUtf8(call ? call->name : ""); + QString o = QString::fromUtf8(QByteArray(out, static_cast(len))); + if (o.size() > 4000) + o = o.left(4000) + QStringLiteral("\n… (truncated for the UI)"); + self->post([self, n, exit_code, o] { emit self->toolResult(n, exit_code, o); }); +} + +void AgentCore::hookTodo(const char *text, void *ud) { + AgentCore *self = static_cast(ud); + const QString t = QString::fromUtf8(text ? text : ""); + self->post([self, t] { emit self->todoChanged(t); }); +} diff --git a/app/src/core/AgentCore.h b/app/src/core/AgentCore.h new file mode 100644 index 0000000..c8cadb4 --- /dev/null +++ b/app/src/core/AgentCore.h @@ -0,0 +1,129 @@ +/* AgentCore: the Qt wrapper around one cst_agent_t. + * + * The engine runs turns on a worker thread; every cst_agent_hooks_t callback + * is turned into a Qt signal, and the two hooks that need an answer + * (on_permission, on_ask_user) block the worker until the GUI replies. No + * HTTP, no socket: the GUI owns the engine in its own process. */ + +#ifndef CST_APP_AGENTCORE_H +#define CST_APP_AGENTCORE_H + +#include +#include +#include +#include +#include + +#include "cst_agent.h" +#include "cst_tools.h" + +class QThread; + +class AgentCore : public QObject { + Q_OBJECT + Q_PROPERTY(bool running READ running NOTIFY runningChanged) + Q_PROPERTY(QString workspace READ workspace NOTIFY workspaceChanged) + Q_PROPERTY(QString mode READ mode NOTIFY modeChanged) + + public: + explicit AgentCore(QObject *parent = nullptr); + ~AgentCore() override; + + /* Start the engine on a workspace. provider/model/apiKey may be empty to + * fall back to the saved credentials and the provider's default model. */ + bool open(const QString &workspace, const QString &provider, + const QString &model, const QString &apiKey, QString *err); + void close(); + + bool running() const { return m_running; } + QString workspace() const { return m_workspace; } + QString mode() const { return QString::fromUtf8(m_cfg.mode); } + + /* The engine, for the parts that need it (tool registration). */ + cst_agent_t *agent() const { return m_agent; } + const cst_agent_config_t *config() const { return &m_cfg; } + + /* Ask a question; emits the streaming signals, then turnFinished(). */ + Q_INVOKABLE void send(const QString &prompt); + Q_INVOKABLE void cancel(); + + /* Consent: answers map to CST_PERM_*. */ + Q_INVOKABLE void answerPermission(int choice); + Q_INVOKABLE void answerQuestion(const QString &answer); + + Q_INVOKABLE void setMode(const QString &mode); /* coding | general */ + Q_INVOKABLE void setApproval(int approval); /* cst_approval_t */ + Q_INVOKABLE void setPlanMode(bool on); + + /* Tools the front end implements itself. The engine copies the definition + * (strings included), so a stack value is fine; ud must outlive it. + * owner is what unregisterOwner() later matches. */ + bool registerTool(const cst_tool_def_t *def); + void unregisterOwner(const QString &owner); + /* Re-probe platform tools after a service appeared or went away. */ + void reloadToolMeta() { cst_tool_meta_reload(); } + + /* Answer every consent question with "once" (the C smoke test, headless + * runs, a --yes flag). */ + void setAutoApprove(bool on) { m_autoApprove = on; } + + signals: + void statusChanged(const QString &text); + void assistantDelta(const QString &text); + void thinkDelta(const QString &text); + void toolEvent(const QString &phase, const QString &name, + const QString &detail); + void toolResult(const QString &name, int exitCode, const QString &output); + void todoChanged(const QString &text); + void permissionRequested(const QString &tool, const QString &detail, + const QString &preview); + void questionAsked(const QString &question, const QString &options); + void turnFinished(int rc, const QString &err); + void runningChanged(); + void workspaceChanged(); + void modeChanged(); + + private: + friend class Turn; + void finishTurn(int rc, const QString &err); + void setRunning(bool on); + + /* Hook trampolines (C, run on the worker thread). */ + static void hookStatus(const char *msg, void *ud); + static void hookAssistant(const char *text, size_t len, void *ud); + static void hookThink(const char *text, size_t len, void *ud); + static void hookToolEvent(const char *phase, const ctl_tool_call_t *call, + const char *detail, void *ud); + static void hookToolResult(const ctl_tool_call_t *call, const char *out, + size_t len, int exit_code, void *ud); + static void hookTodo(const char *text, void *ud); + static int hookPermission(const char *tool, const char *detail, + const char *preview, void *ud); + static int hookAskUser(const char *question, const char *options, char *ans, + size_t ans_sz, void *ud); + /* Post a lambda onto the GUI thread. */ + template + void post(F f) { + QMetaObject::invokeMethod(this, f, Qt::QueuedConnection); + } + + cst_agent_t *m_agent = nullptr; + cst_agent_config_t m_cfg; + cst_agent_hooks_t m_hooks; + QString m_workspace; + QThread *m_turn = nullptr; + bool m_running = false; + bool m_closing = false; + + /* consent handshake between the worker and the GUI thread */ + QMutex m_askMu; + QWaitCondition m_askCv; + int m_permAnswer = CST_PERM_DENY; + bool m_permPending = false; + QString m_question; + QByteArray m_answer; + bool m_questionPending = false; + bool m_autoApprove = false; +}; + +#endif /* CST_APP_AGENTCORE_H */ diff --git a/app/src/core/ToolProvider.cpp b/app/src/core/ToolProvider.cpp new file mode 100644 index 0000000..7ab53ed --- /dev/null +++ b/app/src/core/ToolProvider.cpp @@ -0,0 +1,40 @@ +#include "ToolProvider.h" + +#include "AgentCore.h" + +namespace app { + +/* Keep these in one place: every platform tool of the front end uses one of + * them, so the engine's availability gate and the policy UI agree. */ +const char *const kDesktopOnly[] = {"desktop", NULL}; +const char *const kAndroidOnly[] = {"android", NULL}; + +int registerPlatformTools(AgentCore *core) { + if (!core) + return 0; + int n = 0; +#if defined(__ANDROID__) + n += platformRegisterAndroid(core); +#elif defined(__APPLE__) + n += platformRegisterMac(core); +#elif defined(_WIN32) + n += platformRegisterWindows(core); +#elif defined(__linux__) + n += platformRegisterLinux(core); +#endif + /* A service may have appeared or gone since the last probe (a desktop + * session starting, the notification daemon coming up). */ + cst_tool_meta_reload(); + return n; +} + +void unregisterPlatformTools(AgentCore *core) { + if (!core) + return; + core->unregisterOwner(QStringLiteral("app:linux")); + core->unregisterOwner(QStringLiteral("app:windows")); + core->unregisterOwner(QStringLiteral("app:macos")); + core->unregisterOwner(QStringLiteral("app:android")); +} + +} // namespace app diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h new file mode 100644 index 0000000..6b1eaa0 --- /dev/null +++ b/app/src/core/ToolProvider.h @@ -0,0 +1,59 @@ +/* How the front end hands a tool to the engine. + * + * A tool implemented in C++ is an ordinary cst_tool_def_t: the engine owns a + * copy of the definition, so a static (or stack) value is enough, and + * `ud`/`run_ex` keep pointing at the front end's state. The callback runs on + * the turn's worker thread -- touch Qt objects only through queued signals. + * + * See app/src/platform/linux/NotifyTool.cpp for a worked example, and + * tests/test_embed.c for the same contract without Qt. + */ + +#ifndef CST_APP_TOOLPROVIDER_H +#define CST_APP_TOOLPROVIDER_H + +#include + +#include "cst_tools.h" + +/* The engine wrapper of the front end (core/AgentCore.h). Only a pointer + * travels through this header, so the class stays incomplete here. */ +class AgentCore; + +namespace app { + +/* Fill a definition in one place, so every front-end tool carries the same + * metadata (class + policy name + platform list + probe). */ +inline void setMeta(cst_tool_def_t *def, const char *name, const char *desc, + cst_tool_perm_t perm, const char *perm_name, + const char *const *platforms, + int (*available)(char *, size_t)) { + def->name = name; + def->desc = desc; + def->perm = perm; + def->perm_name = perm_name; + def->platforms = platforms; + def->available = available; +} + +/* Tools that only work on the phone. */ +extern const char *const kAndroidOnly[]; +/* Tools that only work on a desktop (linux / win / macos). */ +extern const char *const kDesktopOnly[]; + +/* The platform tools this app provides. Called once the engine exists; + * returns how many were registered. */ +int registerPlatformTools(AgentCore *core); +/* Drop them again (they belong to the owners "app:*"). */ +void unregisterPlatformTools(AgentCore *core); + +/* One entry point per platform, implemented by the file under + * src/platform//; ToolProvider.cpp picks one at compile time. */ +int platformRegisterLinux(AgentCore *core); +int platformRegisterMac(AgentCore *core); +int platformRegisterWindows(AgentCore *core); +int platformRegisterAndroid(AgentCore *core); + +} // namespace app + +#endif /* CST_APP_TOOLPROVIDER_H */ diff --git a/app/src/main.cpp b/app/src/main.cpp new file mode 100644 index 0000000..d494891 --- /dev/null +++ b/app/src/main.cpp @@ -0,0 +1,155 @@ +/* cst-agent-app: the Qt front end. + * + * cst-agent-app --workspace DIR [--provider p] [--model m] [--api-key k] + * cst-agent-app --selftest embedding checks, no window, no network + * + * The engine runs in this process (see app/README.md); nothing listens on a + * port and no second process is started. */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "core/AgentCore.h" +#include "core/ToolProvider.h" +#include "cst_version.h" + +namespace { + +int runSelftest() { + QTextStream out(stdout); + int bad = 0; + QTemporaryDir tmp; + if (!tmp.isValid()) { + out << "selftest: cannot create a scratch dir\n"; + return 1; + } + AgentCore core; + QString err; + if (!core.open(tmp.path(), QStringLiteral("deepseek"), + QStringLiteral("deepseek-flash"), QStringLiteral("test-key"), + &err)) { + out << "selftest: engine did not start: " << err << "\n"; + return 1; + } + out << "selftest: engine started in-process\n"; + if (core.agent() == nullptr) { + out << "selftest: no agent handle\n"; + return 1; + } + /* the front end's own tools go through the engine's registry */ + const int n = app::registerPlatformTools(&core); + out << "selftest: platform tools registered: " << n << "\n"; + const cst_tool_def_t *t = cst_tool_find("notification_send"); + if (!t) { + out << "selftest: notification_send is not in the registry\n"; + bad++; + } else { + if (t->owner == nullptr || strncmp(t->owner, "app:", 4) != 0) { + out << "selftest: notification_send has no app owner\n"; + bad++; + } + char why[160] = ""; + const bool ok = cst_tool_available(t, why, sizeof(why)) == 0; + out << "selftest: notification_send available=" << (ok ? "yes" : "no") + << (ok ? "" : QStringLiteral(" (%1)").arg(QString::fromUtf8(why))) + << "\n"; + } + /* the mode comes from the engine, not from the UI */ + core.setMode(QStringLiteral("general")); + if (core.mode() != QLatin1String("general")) { + out << "selftest: mode did not stick\n"; + bad++; + } + app::unregisterPlatformTools(&core); + if (cst_tool_find("notification_send") != nullptr) { + out << "selftest: platform tools were not dropped\n"; + bad++; + } + core.close(); + out << (bad ? "selftest: FAILED\n" : "selftest: OK\n"); + out.flush(); + return bad ? 1 : 0; +} + +} // namespace + +int main(int argc, char **argv) { + /* --selftest stays headless: no window, no QML, no display needed. */ + bool selftest = false; + for (int i = 1; i < argc; i++) + if (strcmp(argv[i], "--selftest") == 0) + selftest = true; + if (selftest) { + QCoreApplication app(argc, argv); + return runSelftest(); + } + + QGuiApplication app(argc, argv); + QGuiApplication::setApplicationName(QStringLiteral("cst-agent-app")); + QGuiApplication::setApplicationVersion(QStringLiteral(CST_VERSION)); + QGuiApplication::setOrganizationName(QStringLiteral("cst-agent")); + + QCommandLineParser parser; + parser.setApplicationDescription( + QStringLiteral("CST-agent desktop front end (engine in-process)")); + parser.addHelpOption(); + parser.addVersionOption(); + QCommandLineOption wsOpt({QStringLiteral("w"), QStringLiteral("workspace")}, + QStringLiteral("workspace directory"), + QStringLiteral("dir")); + QCommandLineOption providerOpt(QStringLiteral("provider"), + QStringLiteral("provider name"), + QStringLiteral("name")); + QCommandLineOption modelOpt(QStringLiteral("model"), + QStringLiteral("model name"), QStringLiteral("name")); + QCommandLineOption planOpt(QStringLiteral("plan"), + QStringLiteral("start in plan mode")); + parser.addOption(wsOpt); + parser.addOption(providerOpt); + parser.addOption(modelOpt); + parser.addOption(planOpt); + parser.process(app); + + QString ws = parser.value(wsOpt); + if (ws.isEmpty()) + ws = QDir::currentPath(); + ws = QFileInfo(ws).absoluteFilePath(); + + AgentCore core; + QString err; + if (!core.open(ws, parser.value(providerOpt), parser.value(modelOpt), + QString(), &err)) { + QTextStream(stderr) << "cannot start the engine: " << err << "\n"; + return 1; + } + app::registerPlatformTools(&core); /* the app's own tools */ + if (parser.isSet(planOpt)) + core.setPlanMode(true); + + QQmlApplicationEngine engine; + engine.rootContext()->setContextProperty(QStringLiteral("agent"), &core); + const QUrl url(QStringLiteral("qrc:/qt/qml/CstAgent/qml/Main.qml")); + QObject::connect( + &engine, &QQmlApplicationEngine::objectCreated, &app, + [url](QObject *obj, const QUrl &objUrl) { + if (!obj && url == objUrl) + QCoreApplication::exit(-1); + }, + Qt::QueuedConnection); + engine.load(url); + if (engine.rootObjects().isEmpty()) + return 1; + return app.exec(); +} diff --git a/app/src/platform/linux/NotifyTool.cpp b/app/src/platform/linux/NotifyTool.cpp new file mode 100644 index 0000000..370700f --- /dev/null +++ b/app/src/platform/linux/NotifyTool.cpp @@ -0,0 +1,109 @@ +/* Platform tools on Linux, implemented by the app (not by the engine): they + * exist exactly where the desktop service does, and the engine only ever sees + * the capability -- platforms + probe decide whether the tool is offered. */ + +#include +#include +#include +#include +#include + +#include +#include + +#include "core/AgentCore.h" +#include "core/ToolProvider.h" + +namespace app { +namespace { + +/* whereis-style check without a shell: the same rule the engine uses for the + * clipboard helpers. */ +bool haveProgram(const char *name) { + QByteArray path = qgetenv("PATH"); + const QList dirs = path.split(':'); + for (const QByteArray &d : dirs) { + QByteArray dir = d.isEmpty() ? QByteArray(".") : d; + QByteArray cand = dir + "/" + name; + if (QFileInfo::exists(QString::fromLocal8Bit(cand)) && + QFileInfo(QString::fromLocal8Bit(cand)).isExecutable()) + return true; + } + return false; +} + +/* 0 when notifications can be shown; -1 with the reason otherwise. */ +int notifyProbe(char *why, size_t why_sz) { + if (haveProgram("notify-send")) + return 0; + if (haveProgram("gdbus") && !qgetenv("DBUS_SESSION_BUS_ADDRESS").isEmpty()) + return 0; + snprintf(why, why_sz, + "no notification service (no notify-send, no D-Bus session bus)"); + return -1; +} + +const char *argOf(const ctl_tool_call_t *call, const char *key) { + for (int i = 0; i < call->nargs; i++) + if (strcmp(call->args[i].key, key) == 0) + return call->args[i].value; + return nullptr; +} + +/* Runs on the turn's worker thread: no GUI objects here. */ +int notifyRun(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)ud; + const char *title = argOf(call, "title"); + const char *message = argOf(call, "message"); + if (!message || !message[0]) { + const char *msg = "error: notification_send needs a message"; + r->out = strdup(msg); + r->out_len = r->out ? strlen(msg) : 0; + r->exit_code = 1; + return 0; + } + QStringList args; + if (title && title[0]) + args << QString::fromUtf8(title); + else + args << QStringLiteral("CST-agent"); + args << QString::fromUtf8(message); + const int rc = QProcess::execute(QStringLiteral("notify-send"), args); + const QByteArray text = + (rc == 0 ? QByteArrayLiteral("notification sent") + : QByteArrayLiteral("notify-send failed (rc=") + + QByteArray::number(rc) + QByteArrayLiteral(")")); + r->out = strdup(text.constData()); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = rc == 0 ? 0 : 1; + return 0; +} + +/* Registered once; the engine keeps a copy until the owner is unregistered. */ +cst_tool_def_t g_notify; + +} // namespace + +int platformRegisterLinux(AgentCore *core) { + static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { + {"message", "string", 1, "text of the notification"}, + {"title", "string", 0, "title line (default: CST-agent)"}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + memset(&g_notify, 0, sizeof(g_notify)); + setMeta(&g_notify, "notification_send", + "Show a desktop notification (what the user sees outside the app).", + CST_TP_ASK, "notify", kDesktopOnly, notifyProbe); + memcpy(g_notify.params, params, sizeof(params)); + g_notify.run = nullptr; + g_notify.run_ex = notifyRun; + g_notify.ud = nullptr; + g_notify.owner = "app:linux"; + return core->registerTool(&g_notify) ? 1 : 0; +} + +} // namespace app diff --git a/app/tools/selftest.c b/app/tools/selftest.c new file mode 100644 index 0000000..0a5e275 --- /dev/null +++ b/app/tools/selftest.c @@ -0,0 +1,151 @@ +/* The app's C smoke test: does the engine embed the way app/ needs it to? + * + * It links libcstcore.a (or the cstcore target) and runs without Qt, so the + * C side of the GUI stays verifiable on any machine -- including CI and the + * Android cross-build. `cst-agent-app --selftest` runs the same checks inside + * the real process. */ + +#define _POSIX_C_SOURCE 200809L + +#include "cst_agent.h" +#include "cst_tools.h" + +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#define getpid _getpid +#else +#include +#include +#endif + +static int failures; + +#define CHECK(cond, what) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, what); \ + failures++; \ + } \ + } while (0) + +/* A tool the app would implement (the Linux build uses notify-send; here it + * only has to prove the plumbing). */ +static int ctx_runs; +static int app_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, + cst_tool_result_t *r, void *ud) { + (void)ctx; + (void)call; + (void)ud; + ctx_runs++; + r->out = strdup("app tool ran"); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = 0; + return 0; +} + +static int probe_missing(char *why, size_t why_sz) { + snprintf(why, why_sz, "the desktop service is not running"); + return -1; +} + +int main(void) { + char ws[1024]; + snprintf(ws, sizeof(ws), "/tmp/cst-app-selftest-%d", (int)getpid()); +#ifdef _WIN32 + _mkdir(ws); /* the engine wants its workspace to exist */ +#else + mkdir(ws, 0700); +#endif + + cst_agent_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", ws); + snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", ws); + snprintf(cfg.provider, sizeof(cfg.provider), "deepseek"); + snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash"); + snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1"); + snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key"); + snprintf(cfg.mode, sizeof(cfg.mode), "general"); + cfg.max_steps = 1; + + cst_agent_t *a = cst_agent_create(&cfg); + CHECK(a != NULL, "the app can start an engine in its own process"); + if (!a) { + fprintf(stderr, "cst-app-selftest: cannot start the engine\n"); + return 1; + } + + /* 1. a tool that exists here (no probe) is offered to the model */ + cst_tool_def_t plain; + memset(&plain, 0, sizeof(plain)); + plain.name = "app_ping"; + plain.desc = "A tool the app provides."; + plain.perm = CST_TP_READ; + plain.run_ex = app_tool_run; + plain.owner = "app:selftest"; + CHECK(cst_tool_register(&plain) == 0, "register an app tool"); + const cst_tool_def_t *t = cst_tool_find("app_ping"); + CHECK(t != NULL, "it is findable"); + CHECK(t && cst_tool_active(cst_agent_config(a), t), "and it is offered"); + ctl_tool_call_t call; + memset(&call, 0, sizeof(call)); + snprintf(call.name, sizeof(call.name), "app_ping"); + cst_tool_result_t res; + cst_tool_ctx_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.workspace_root = ws; + ctx.agent = a; + cst_tool_run(&ctx, &call, &res); + CHECK(ctx_runs == 1, "the app callback ran"); + CHECK(res.out && strcmp(res.out, "app tool ran") == 0, "its result came back"); + cst_tool_result_free(&res); + + /* 2. a tool whose platform service is missing is never offered ... */ + cst_tool_def_t missing; + memset(&missing, 0, sizeof(missing)); + missing.name = "app_no_service"; + missing.desc = "Needs a service that is not here."; + missing.perm = CST_TP_ASK; + missing.perm_name = "notify"; + missing.available = probe_missing; + missing.run_ex = app_tool_run; + missing.owner = "app:selftest"; + CHECK(cst_tool_register(&missing) == 0, "register a tool with a probe"); + const cst_tool_def_t *mt = cst_tool_find("app_no_service"); + char why[160] = ""; + CHECK(mt && cst_tool_available(mt, why, sizeof(why)) != 0, + "the probe says it cannot run"); + CHECK(strstr(why, "desktop service") != NULL, "and passes its reason on"); + CHECK(mt && !cst_tool_active(cst_agent_config(a), mt), + "so it stays out of tools[] and the prompt"); + snprintf(call.name, sizeof(call.name), "app_no_service"); + cst_tool_run(&ctx, &call, &res); + CHECK(res.out && strstr(res.out, "cannot run here") != NULL, + "asking for it anyway is answered with the reason"); + CHECK(ctx_runs == 1, "and its callback never ran"); + cst_tool_result_free(&res); + + /* 3. the app can drop its tools again */ + CHECK(cst_tool_unregister_owner("app:selftest") == 2, "unregister both"); + CHECK(cst_tool_find("app_ping") == NULL, "app_ping is gone"); + CHECK(cst_tool_find("read_file") != NULL, "the built-ins are untouched"); + + cst_agent_destroy(a); + { + char cmd[1200]; + snprintf(cmd, sizeof(cmd), "rm -rf %s", ws); + if (system(cmd) != 0) + fprintf(stderr, "note: could not clean %s\n", ws); + } + if (failures) { + fprintf(stderr, "cst-app-selftest: %d failure(s)\n", failures); + return 1; + } + printf("cst-app-selftest: OK\n"); + return 0; +} diff --git a/docs/android.md b/docs/android.md new file mode 100644 index 0000000..df3dab8 --- /dev/null +++ b/docs/android.md @@ -0,0 +1,73 @@ +# CST-agent on Android + +The engine runs on the phone as a plain bionic binary -- no Termux, no proot, +no second process for the UI to talk to. It is the same C core the CLI, the +TUI and the WebUI use, cross-compiled with the NDK; on the phone the Qt front +end (app/) will link `libcstcore.a` in-process and register its own platform +tools under the owner `app:android`. + +Two scripts cover the engine side: + +| script | what it does | +| --- | --- | +| `scripts/build_third_party_android.sh ABI [all\|mbedtls\|curl]` | static mbedTLS + libcurl into `third_party/prefix-android-ABI` | +| `scripts/build_android.sh [ABI] [--push] [--run] [--run-tests]` | `libcstcore.a`, `cst-agent`, the test binaries; optional push and on-device run | + +## Toolchain + +NixOS: nixpkgs carries the NDK (the Android SDK license makes it unfree, hence +the flag; the packaged NDK has a patched interpreter, so its clang runs here): + +```sh +export ANDROID_NDK_HOME=$(NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link \ + --print-out-paths 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle +``` + +`adb` comes from `nix-shell -p android-tools`; the builds need cmake, ninja and +curl. End to end: + +```sh +nix-shell -p cmake ninja curl android-tools --run ' + JOBS=16 ./scripts/build_third_party_android.sh arm64-v8a all' +nix-shell -p cmake ninja curl android-tools --run ' + JOBS=16 ./scripts/build_android.sh arm64-v8a --push --run' +``` + +`--run` finishes with the two smoke checks on the device (`cst-agent +--version` and `tests/test_embed`, the in-process embedding contract); +`--run-tests` builds the whole `tests/` suite, pushes it and runs every binary +on the phone, printing a pass/fail line per test. + +## What is Android-specific + +* **API level 24** (`API_LEVEL=24`): bionic provides threads, `exec` and + `/system/etc/security/cacerts`, which is where libcurl is pointed + (`CURL_CA_PATH`). Bump the level if a newer syscall or CA layout is needed. +* **No TUI, no WebUI**: `-DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF` -- there is + no ncurses on Android, and the phone front end talks to the engine in-process + instead of over HTTP. `src/webui_stub.c` compiles in place of the server. +* **Sandbox**: `src/sandbox_exec.c` only enables the seccomp/Landlock paths + under `defined(__linux__) && !defined(__ANDROID__)`, so the phone build gets + the plain exec path -- the same rule the Termux build follows. + The seccomp supervisor used to compile in and the probe reported "seccomp", + but on the device a write outside the workspace was not denied + (`tests/test_sandbox.c:308`), so the phone build now reports no sandbox + instead of one that does not hold. +* **sashidown** is built per ABI (`vendor/sashidown/libsashidown-.a`) with + the NDK's `aarch64-linux-android24-clang` wrapper: CMake's compiler is plain + clang and keeps `--target`/`--sysroot` in its own variables, which the + vendored makefile cannot see. The host archive stays host-built. +* **Static libcurl** needs `-L$PREFIX/lib` on the link line, because curl's own + CMake config lists its mbedTLS dependencies as `-l` flags rather than paths. + +## Status + +* arm64-v8a, Android 16 (NX789J): `cst-agent` runs, `tests/test_embed` passes. +* The whole suite runs on the device (`scripts/build_android.sh arm64-v8a + --run-tests`): 36/36 after the diff test learned to tell toybox's `diff` from + GNU diffutils. +* Still to come: the Qt front end packaged as an APK. nixpkgs has no Qt for + Android build, so that needs a Qt host kit plus the `android_arm64_v8a` kit + (qt.io or aqtinstall), a JDK and the Android SDK platform/build-tools; + `app/src/platform/android/` (JNI tools, owner `app:android`) and the Gradle + project under `app/android/` do not exist yet. diff --git a/include/cst_clipboard.h b/include/cst_clipboard.h index e75d616..83d5b98 100644 --- a/include/cst_clipboard.h +++ b/include/cst_clipboard.h @@ -31,6 +31,10 @@ int cst_clipboard_set_text(const char *text, size_t n, char *err, * for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */ char *cst_dib_to_image(const void *dib, size_t sz, size_t *n); +/* 0 when this host has a clipboard to talk to (a helper on PATH, or the + * Win32/AppKit API), -1 with a reason otherwise. Cheap: no forks. */ +int cst_clipboard_available(char *why, size_t why_sz); + #define CST_PASTE_MAX_PATHS 8 #define CST_PASTE_PATH_MAX 1024 diff --git a/include/cst_tools.h b/include/cst_tools.h index f815c6a..211d4ec 100644 --- a/include/cst_tools.h +++ b/include/cst_tools.h @@ -62,6 +62,14 @@ typedef int (*cst_tool_run_ex_fn)(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, cst_tool_result_t *r, void *ud); +/* Platforms a tool exists on, for def.platforms (NULL-terminated). + * CST_PLAT_DESKTOP covers linux / win / macos; an app-provided tool can + * declare its own, e.g. a static {"android", NULL} in the Qt frontend. */ +extern const char *const CST_PLAT_DESKTOP[]; +/* Host platform: "linux" | "win" | "macos" | "android" | "termux" | "other". + * CST_PLATFORM overrides it (tests, cross checks). */ +const char *cst_platform_name(void); + typedef struct { const char *name; const char *desc; @@ -81,10 +89,34 @@ typedef struct { const char *owner; /* "plugin:", "mcp:", ... */ /* Non-READ tools that may still join a parallel batch for this call. */ int (*parallel)(const ctl_tool_call_t *call, void *ud); + /* --- capability metadata (discovery + availability) ------------------- */ + /* Fine-grained policy name for permissions.json and consent UIs + * ("read","write","delete","shell","net","notify","device","ui","meta"). + * NULL falls back to the class name of perm. */ + const char *perm_name; + /* Platforms the tool exists on (NULL = everywhere). A tool that is not + * for this host never reaches the prompt or tools[]. */ + const char *const *platforms; + /* Probe: 0 when the tool can run here, -1 with a reason in why (NULL = + * always). Keep it cheap (no forks); the answer is cached until + * cst_tool_meta_reload(). */ + int (*available)(char *why, size_t why_sz); } cst_tool_def_t; +/* 0 when t exists on this platform. */ +int cst_tool_platform_ok(const cst_tool_def_t *t); +/* 0 when the tool can run here, -1 with a reason in why. Cached. */ +int cst_tool_available(const cst_tool_def_t *t, char *why, size_t why_sz); +/* Drop cached probes (a platform service appeared or went away). */ +void cst_tool_meta_reload(void); +/* Policy name of t: perm_name, or the class default of perm. */ +const char *cst_tool_perm_name(const cst_tool_def_t *t); + /* Add a tool at runtime (strings are copied, ud is not). Safe while turns - * run; returns 0, or -1 when the name is taken or the table is full. */ + * run; returns 0, or -1 when the name is taken or the table is full. An + * app-provided tool may take a name whose built-in cannot run on this host + * (the Android clipboard replacing the xclip one) and then shadows it for + * lookups: one capability, one name. */ int cst_tool_register(const cst_tool_def_t *def); /* Drop every dynamic tool of owner (call only while no turn runs). */ int cst_tool_unregister_owner(const char *owner); diff --git a/include/cst_version.h b/include/cst_version.h index a64aa6f..8cee0a0 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 6 +#define CST_VERSION_PATCH 7 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/build_android.sh b/scripts/build_android.sh new file mode 100755 index 0000000..7875e86 --- /dev/null +++ b/scripts/build_android.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# Cross-build the engine for Android: libcstcore.a (what the APK embeds) plus +# cst-agent and tests/test_embed, which run straight from /data/local/tmp. +# No TUI (no ncurses on Android) and no WebUI (the phone app talks to the +# engine in-process; the stub compiles instead of the HTTP server). +# +# scripts/build_android.sh [arm64-v8a|x86_64] [--push] [--run] [--run-tests] +# +# --push copy the binaries to /data/local/tmp +# --run also smoke-test there: cst-agent --version, test_embed +# --run-tests build and run the whole tests/ suite on the device +# +# Needs ANDROID_NDK_HOME (see build_third_party_android.sh) and the deps from +# scripts/build_third_party_android.sh ABI all. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ABI="arm64-v8a" +DO_PUSH=0 +DO_RUN=0 +DO_TESTS=0 +for a in "$@"; do + case "$a" in + arm64-v8a | x86_64) ABI="$a" ;; + --push) DO_PUSH=1 ;; + --run) DO_PUSH=1; DO_RUN=1 ;; + --run-tests) DO_PUSH=1; DO_TESTS=1 ;; + *) echo "usage: $0 [arm64-v8a|x86_64] [--push] [--run|--run-tests]" >&2; exit 1 ;; + esac +done + +API_LEVEL="${API_LEVEL:-24}" +PREFIX="$ROOT/third_party/prefix-android-$ABI" +BUILD="$ROOT/build-android-$ABI" +JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}" + +NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" +if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then + echo "set ANDROID_NDK_HOME to an NDK (see build_third_party_android.sh)" >&2 + exit 1 +fi +if [[ ! -f "$PREFIX/lib/libcurl.a" ]]; then + echo "missing $PREFIX/lib/libcurl.a -- run:" >&2 + echo " scripts/build_third_party_android.sh $ABI all" >&2 + exit 1 +fi + +cmake -S "$ROOT" -B "$BUILD" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$NDK/build/cmake/android.toolchain.cmake" \ + -DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \ + -DANDROID_NDK="$NDK" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH="$PREFIX" \ + -DCURL_INCLUDE_DIR="$PREFIX/include" -DCURL_LIBRARY="$PREFIX/lib/libcurl.a" \ + -DCMAKE_EXE_LINKER_FLAGS="-L$PREFIX/lib" \ + -DCST_BUILD_TUI=OFF -DCST_BUILD_WEBUI=OFF -DCST_BUILD_APP=OFF \ + -DCST_BUNDLE_BUSYBOX=OFF +if [[ "$DO_TESTS" == 1 ]]; then + cmake --build "$BUILD" -j"$JOBS" # every test target +else + cmake --build "$BUILD" -j"$JOBS" --target cst-agent test_embed +fi +echo "built in $BUILD" +[[ "$DO_PUSH" == 1 ]] || exit 0 + +ADB="${ADB:-adb}" +command -v "$ADB" >/dev/null || { echo "adb not found (nix-shell -p android-tools)" >&2; exit 1; } +DEV=/data/local/tmp +NAMES=(cst-agent test_embed) +FILES=("$BUILD/cst-agent" "$BUILD/test_embed") +TESTS=() +if [[ "$DO_TESTS" == 1 ]]; then + mapfile -t TESTS < <(find "$BUILD" -maxdepth 1 -type f -name 'test_*' -perm -u+x | sort) + for t in "${TESTS[@]}"; do + NAMES+=("${t##*/}") + FILES+=("$t") + done +fi +"$ADB" push "${FILES[@]}" "$DEV/" >/dev/null +"$ADB" shell chmod 755 "${NAMES[@]/#/$DEV/}" +echo "pushed ${#NAMES[@]} binaries to $DEV" +[[ "$DO_PUSH" == 1 && ( "$DO_RUN" == 1 || "$DO_TESTS" == 1 ) ]] || exit 0 + +echo "--- $DEV/cst-agent --version" +"$ADB" shell "$DEV/cst-agent --version" +if [[ "$DO_TESTS" == 0 ]]; then + echo "--- $DEV/test_embed" + "$ADB" shell "cd $DEV && ./test_embed" + exit 0 +fi + +# The suite runs on the device as the shell user; tests that want a real +# /bin/sh, a host fixture tree or a Linux-specific sandbox report their own +# failure, which is the point of running them here. +# The sandbox test builds its scratch trees under $PWD/tests, the same way +# ctest runs it from the repo root. +"$ADB" shell mkdir -p "$DEV/tests" +pass=0; fail=0; skipped=() +for t in "${TESTS[@]}"; do + name="${t##*/}" + if "$ADB" shell "cd $DEV && ./$name" >/dev/null 2>&1; then + pass=$((pass + 1)); echo "ok $name" + else + fail=$((fail + 1)); skipped+=("$name"); echo "FAIL $name" + fi +done +echo "--- $pass passed, $fail failed" +[[ "$fail" == 0 ]] || { printf 'failed: %s\n' "${skipped[*]}"; exit 1; } diff --git a/scripts/build_third_party_android.sh b/scripts/build_third_party_android.sh new file mode 100755 index 0000000..398eb75 --- /dev/null +++ b/scripts/build_third_party_android.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Static deps for Android into third_party/prefix-android-ABI: mbedTLS and +# libcurl (HTTP(S) only, mbedTLS), the same pair the Linux release uses -- +# a phone has no system libcurl and bionic's CA store is /system/etc/security, +# so libcurl is built here rather than taken from the platform. +# +# scripts/build_third_party_android.sh arm64-v8a|x86_64 [all|mbedtls|curl] +# +# Needs the NDK in ANDROID_NDK_HOME (a directory with +# build/cmake/android.toolchain.cmake). On NixOS the packaged one is: +# export ANDROID_NDK_HOME=$( +# NIXPKGS_ALLOW_UNFREE=1 nix build --impure --no-link --print-out-paths \ +# 'nixpkgs#androidenv.androidPkgs.ndk-bundle')/libexec/android-sdk/ndk-bundle +# Host tools: cmake ninja make. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ABI="${1:?usage: $0 arm64-v8a|x86_64 [all|mbedtls|curl]}" +TP="$ROOT/third_party" +PREFIX="$TP/prefix-android-$ABI" +SRC="$TP/src" +WORK="$TP/build-android-$ABI" +JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}" + +MBEDTLS_VER=3.6.7 +MBEDTLS_SHA=a7e8bcbec0e6f761b4af24f25677626b35f762f68eef79c08677a363212d11f6 +CURL_VER=8.22.0 +CURL_SHA=f7ef3ae8a22e521f289803fe93543eb64c329b58aa73a9e224dfd915a2a5f4f7 + +# Android 7.0: what bionic gives the engine (threads, exec, /system CA store). +API_LEVEL="${API_LEVEL:-24}" +# Per-app CAs land in the user store; the system bundle is the readable one. +CA_PATH=/system/etc/security/cacerts + +NDK="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" +if [[ -z "$NDK" || ! -f "$NDK/build/cmake/android.toolchain.cmake" ]]; then + echo "set ANDROID_NDK_HOME to an NDK (see the header of this script)" >&2 + exit 1 +fi +TOOLCHAIN="$NDK/build/cmake/android.toolchain.cmake" + +need() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; } +need cmake +need ninja +mkdir -p "$PREFIX" "$SRC" "$WORK" + +fetch() { # file sha url... + local file="$1" sha="$2" u + shift 2 + if [[ -f "$SRC/$file" ]] && echo "$sha $SRC/$file" | sha256sum -c --quiet - 2>/dev/null; then + return 0 + fi + for u in "$@"; do + echo " try $u" + if curl -fsSL --retry 2 --connect-timeout 15 --max-time 600 -o "$SRC/$file.part" "$u" && + echo "$sha $SRC/$file.part" | sha256sum -c --quiet -; then + mv "$SRC/$file.part" "$SRC/$file" + return 0 + fi + done + rm -f "$SRC/$file.part" + echo "download failed: $file" >&2 + exit 1 +} + +unpack() { # file dir + rm -rf "${WORK:?}/$2" + tar -C "$WORK" -xf "$SRC/$1" +} + +gh() { # path -> GitHub URL candidates (CN proxies first) + printf '%s\n' "https://ghfast.top/https://github.com/$1" \ + "https://ghproxy.net/https://github.com/$1" "https://github.com/$1" +} + +cmake_android() { # src build args... + local s="$1" b="$2" + shift 2 + cmake -S "$s" -B "$b" -G Ninja \ + -DCMAKE_TOOLCHAIN_FILE="$TOOLCHAIN" \ + -DANDROID_ABI="$ABI" -DANDROID_PLATFORM="android-$API_LEVEL" \ + -DANDROID_NDK="$NDK" \ + -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DCMAKE_PREFIX_PATH="$PREFIX" -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_POSITION_INDEPENDENT_CODE=OFF \ + "$@" + cmake --build "$b" -j"$JOBS" + cmake --install "$b" +} + +build_mbedtls() { + local f="mbedtls-$MBEDTLS_VER.tar.bz2" + mapfile -t urls < <(gh "Mbed-TLS/mbedtls/releases/download/mbedtls-$MBEDTLS_VER/$f") + fetch "$f" "$MBEDTLS_SHA" "${urls[@]}" + unpack "$f" "mbedtls-$MBEDTLS_VER" + # 3.6.x keeps its own entropy source on Android, no /dev/urandom probing. + cmake_android "$WORK/mbedtls-$MBEDTLS_VER" "$WORK/mbedtls-$MBEDTLS_VER/build" \ + -DENABLE_TESTING=OFF -DENABLE_PROGRAMS=OFF -DGEN_FILES=OFF \ + -DUSE_STATIC_MBEDTLS_LIBRARY=ON -DUSE_SHARED_MBEDTLS_LIBRARY=OFF \ + -DMBEDTLS_FATAL_WARNINGS=OFF + echo "mbedTLS $MBEDTLS_VER -> $PREFIX" +} + +build_curl() { + local f="curl-$CURL_VER.tar.xz" + mapfile -t urls < <(gh "curl/curl/releases/download/curl-${CURL_VER//./_}/$f") + fetch "$f" "$CURL_SHA" "https://curl.se/download/$f" "${urls[@]}" + unpack "$f" "curl-$CURL_VER" + cmake_android "$WORK/curl-$CURL_VER" "$WORK/curl-$CURL_VER/build" \ + -DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON \ + -DCURL_USE_MBEDTLS=ON -DCURL_USE_OPENSSL=OFF \ + -DMBEDTLS_INCLUDE_DIR="$PREFIX/include" \ + -DMBEDTLS_LIBRARY="$PREFIX/lib/libmbedtls.a" \ + -DMBEDX509_LIBRARY="$PREFIX/lib/libmbedx509.a" \ + -DMBEDCRYPTO_LIBRARY="$PREFIX/lib/libmbedcrypto.a" \ + -DCURL_CA_BUNDLE=none -DCURL_CA_PATH="$CA_PATH" \ + -DHTTP_ONLY=ON -DCURL_DISABLE_LDAP=ON -DCURL_USE_LIBPSL=OFF \ + -DCURL_USE_LIBSSH2=OFF -DCURL_USE_LIBSSH=OFF -DUSE_LIBIDN2=OFF \ + -DUSE_NGHTTP2=OFF -DCURL_ZLIB=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \ + -DCURL_USE_GSSAPI=OFF -DENABLE_THREADED_RESOLVER=ON \ + -DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF \ + -DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DENABLE_CURL_MANUAL=OFF + echo "curl $CURL_VER -> $PREFIX" +} + +case "${2:-all}" in +mbedtls) build_mbedtls ;; +curl) build_curl ;; +all) + build_mbedtls + build_curl + ;; +*) + echo "usage: $0 ABI [all|mbedtls|curl]" >&2 + exit 1 + ;; +esac +echo "DONE android-$ABI" diff --git a/src/clipboard.c b/src/clipboard.c index 1c5fc3c..eb1e2f6 100644 --- a/src/clipboard.c +++ b/src/clipboard.c @@ -18,6 +18,51 @@ static void set_err(char *err, size_t err_sz, const char *msg) { snprintf(err, err_sz, "%s", msg); } +#ifndef _WIN32 +/* PATH lookup without shelling out: cheap enough for a capability probe. */ +static int on_path(const char *name) { + const char *path = getenv("PATH"); + if (!path) + return 0; + for (const char *p = path;;) { + const char *colon = strchr(p, ':'); + size_t len = colon ? (size_t)(colon - p) : strlen(p); + char cand[4096]; + if (len + strlen(name) + 2 < sizeof(cand)) { + if (len == 0) + snprintf(cand, sizeof(cand), "./%s", name); + else + snprintf(cand, sizeof(cand), "%.*s/%s", (int)len, p, name); + if (access(cand, X_OK) == 0) + return 1; + } + if (!colon) + break; + p = colon + 1; + } + return 0; +} +#endif + +int cst_clipboard_available(char *why, size_t why_sz) { + if (why && why_sz) + why[0] = 0; +#ifdef _WIN32 + return 0; /* the Win32 clipboard API is always there */ +#elif defined(__APPLE__) + return 0; /* pbpaste / pbcopy ship with the OS */ +#else + static const char *const helpers[] = {"wl-paste", "wl-copy", "xclip", NULL}; + for (int i = 0; helpers[i]; i++) + if (on_path(helpers[i])) + return 0; + if (why && why_sz) + snprintf(why, why_sz, + "no clipboard helper on PATH (install wl-clipboard or xclip)"); + return -1; +#endif +} + static void put_le32(unsigned char *p, unsigned long v) { p[0] = (unsigned char)(v & 0xff); p[1] = (unsigned char)((v >> 8) & 0xff); diff --git a/src/sandbox_exec.c b/src/sandbox_exec.c index 8cabcf8..3d827e7 100644 --- a/src/sandbox_exec.c +++ b/src/sandbox_exec.c @@ -24,7 +24,13 @@ #include #endif -#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) +/* Android keeps the plain exec path, like its Landlock branch above: the NDK + * build compiles the seccomp supervisor, the probe then reports "seccomp", + * but on the device the writes outside the workspace are not denied + * (tests/test_sandbox.c:308). Claiming a sandbox that does not hold is worse + * than reporting none -- see docs/android.md. */ +#if defined(__linux__) && !defined(__ANDROID__) && \ + (defined(__x86_64__) || defined(__aarch64__)) #include #include #include diff --git a/src/tools/registry.c b/src/tools/registry.c index 535af30..ccd6e25 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -9,6 +9,7 @@ #include #include "cJSON.h" +#include "cst_clipboard.h" #include "cst_tools.h" #include "cst_util.h" #include "tools_internal.h" @@ -44,6 +45,7 @@ static int run_shell(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, #define I "integer" #define B "boolean" #define NO_DYN NULL, NULL, NULL, NULL, NULL +#define NO_META NULL, NULL, NULL static const cst_tool_def_t TOOLS[] = { {"read_file", @@ -55,7 +57,7 @@ static const cst_tool_def_t TOOLS[] = { {"offset", I, 0, "first line to return (1-based)"}, {"limit", I, 0, "maximum number of lines"}, {"line_numbers", B, 0, "`N|` line prefixes (default true)"}}, - CST_TP_READ, 1, run_read_file, NULL, NO_DYN}, + CST_TP_READ, 1, run_read_file, NULL, NO_DYN, NO_META}, {"view_image", "Show a local image to yourself: PNG, JPEG, GIF, WebP or BMP, checked by " "content. The file is copied into the session store and comes back as an " @@ -64,7 +66,7 @@ static const cst_tool_def_t TOOLS[] = { NULL, {{"path", S, 1, "image file path relative to the workspace"}, {"detail", S, 0, "low or high (provider hint; default auto)"}}, - CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN}, + CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN, NO_META}, {"run_shell", "Run a non-interactive shell command in the workspace root: builds, " "tests, git, scripts, computations, data processing, other CLIs " @@ -74,14 +76,14 @@ static const cst_tool_def_t TOOLS[] = { "Run a shell command in the workspace root (non-interactive, stdin is " "empty, killed after a timeout). Returns combined stdout+stderr.", {{"cmd", S, 1, "shell command"}}, - CST_TP_SHELL, 1, run_shell, NULL, NO_DYN}, + CST_TP_SHELL, 1, run_shell, NULL, NO_DYN, NO_META}, {"write_file", "Create a file or replace a whole file (parent directories are " "created). For changes to existing files prefer edit_file.", "Create or overwrite a whole file under the workspace (parent " "directories are created).", {{"path", S, 1, "file path"}, {"content", S, 1, "complete new content"}}, - CST_TP_WRITE, 1, run_write_file, cst_preview_write_file, NO_DYN}, + CST_TP_WRITE, 1, run_write_file, cst_preview_write_file, NO_DYN, NO_META}, {"apply_patch", "Apply a unified diff (---/+++ headers, @@ -a,b +c,d @@ hunks, 3 " "context lines) across one or more files; all hunks apply or nothing " @@ -90,7 +92,7 @@ static const cst_tool_def_t TOOLS[] = { "context lines) to one or more files. All hunks must apply or nothing " "changes.", {{"patch", S, 1, "unified diff text"}}, - CST_TP_WRITE, 1, run_apply_patch, cst_preview_apply_patch, NO_DYN}, + CST_TP_WRITE, 1, run_apply_patch, cst_preview_apply_patch, NO_DYN, NO_META}, {"edit_file", "Replace an exact snippet in a file — the default for small edits. " "old_string must match byte-for-byte (indentation included, without " @@ -100,7 +102,7 @@ static const cst_tool_def_t TOOLS[] = { {"old_string", S, 1, "exact text to replace"}, {"new_string", S, 1, "replacement text"}, {"replace_all", B, 0, "replace every occurrence"}}, - CST_TP_WRITE, 0, cst_tool_edit_file, cst_preview_edit_file, NO_DYN}, + CST_TP_WRITE, 0, cst_tool_edit_file, cst_preview_edit_file, NO_DYN, NO_META}, {"list_dir", "Show a directory tree (dirs end with /, files show sizes, dirs at the " "depth limit show entry counts). Skips .git, node_modules and " @@ -108,7 +110,7 @@ static const cst_tool_def_t TOOLS[] = { NULL, {{"path", S, 0, "directory (default: workspace root)"}, {"depth", I, 0, "levels to descend (default 2, max 6)"}}, - CST_TP_READ, 0, cst_tool_list_dir, NULL, NO_DYN}, + CST_TP_READ, 0, cst_tool_list_dir, NULL, NO_DYN, NO_META}, {"glob", "Find files by name, newest first (max 200). `*`/`?` stay within one " "path segment, `**` spans directories (`src/**/*.c`); a pattern without " @@ -116,7 +118,7 @@ static const cst_tool_def_t TOOLS[] = { NULL, {{"pattern", S, 1, "glob relative to path"}, {"path", S, 0, "directory to search (default: workspace root)"}}, - CST_TP_READ, 0, cst_tool_glob, NULL, NO_DYN}, + CST_TP_READ, 0, cst_tool_glob, NULL, NO_DYN, NO_META}, {"grep", "Search file contents with an extended regex (\\d \\w \\s work). " "Results are grouped by file as `line:text` under each path, with a " @@ -127,51 +129,53 @@ static const cst_tool_def_t TOOLS[] = { {"glob", S, 0, "only files whose name matches, e.g. *.c"}, {"ignore_case", B, 0, "case-insensitive"}, {"context", I, 0, "lines of context around each match (max 5)"}}, - CST_TP_READ, 0, cst_tool_grep, NULL, NO_DYN}, + CST_TP_READ, 0, cst_tool_grep, NULL, NO_DYN, NO_META}, {"delete_file", "Delete one file (not directories). Undoable with /undo.", NULL, {{"path", S, 1, "file path"}}, - CST_TP_WRITE, 0, cst_tool_delete_file, cst_preview_delete_file, NO_DYN}, + CST_TP_WRITE, 0, cst_tool_delete_file, cst_preview_delete_file, NO_DYN, + "delete", NULL, NULL}, {"move_file", "Move or rename a file; fails if the destination exists.", NULL, {{"from", S, 1, "existing file"}, {"to", S, 1, "new path"}}, - CST_TP_WRITE, 0, cst_tool_move_file, cst_preview_move_file, NO_DYN}, + CST_TP_WRITE, 0, cst_tool_move_file, cst_preview_move_file, NO_DYN, NO_META}, {"todo_write", "Replace your task list for work with 3+ steps; keep exactly one item " "in progress. One item per line: `[ ] todo`, `[~] in progress`, `[x] " "done`.", NULL, {{"todos", S, 1, "the whole list, one item per line"}}, - CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN}, + CST_TP_META, 0, cst_tool_todo_write, NULL, NO_DYN, NO_META}, {"web_fetch", "Fetch an http(s) URL (docs, issues, APIs) and return its text; HTML " "is reduced to readable text.", NULL, {{"url", S, 1, "http:// or https:// URL"}, {"max_bytes", I, 0, "maximum bytes to return (default 65536)"}}, - CST_TP_NET, 0, cst_tool_web_fetch, NULL, NO_DYN}, + CST_TP_NET, 0, cst_tool_web_fetch, NULL, NO_DYN, NO_META}, {"ask_user", "Ask the user and wait for the answer. Only for decisions that are " "truly theirs (preferences, credentials, destructive choices).", NULL, {{"question", S, 1, "the question"}, {"options", S, 0, "suggested answers, one per line"}}, - CST_TP_ASK, 0, cst_tool_ask_user, NULL, NO_DYN}, + CST_TP_ASK, 0, cst_tool_ask_user, NULL, NO_DYN, NO_META}, {"clipboard_read", "Read the text on the system clipboard — what the user copied outside " "the workspace. Returns UTF-8 text.", NULL, {{"max_bytes", I, 0, "cap on the returned text (default 65536)"}}, - CST_TP_READ, 0, cst_tool_clipboard_read, NULL, NO_DYN}, + CST_TP_READ, 0, cst_tool_clipboard_read, NULL, NO_DYN, "read", + CST_PLAT_DESKTOP, cst_clipboard_available}, {"clipboard_write", "Replace the system clipboard with text, so the user can paste it into " "another app. Overwrites what they had copied.", NULL, {{"text", S, 1, "text to put on the clipboard"}}, CST_TP_WRITE, 0, cst_tool_clipboard_write, cst_preview_clipboard_write, - NO_DYN}, + NO_DYN, "write", CST_PLAT_DESKTOP, cst_clipboard_available}, }; #define NTOOLS ((int)(sizeof(TOOLS) / sizeof(TOOLS[0]))) @@ -179,6 +183,7 @@ static const cst_tool_def_t TOOLS[] = { #undef I #undef B #undef NO_DYN +#undef NO_META int cst_tool_profile_parse(const char *s, int *out) { if (!s || !out) @@ -231,11 +236,129 @@ int cst_tool_index(const char *name) { } const cst_tool_def_t *cst_tool_find(const char *name) { + if (!name) + return NULL; + /* An app-provided tool of the same name shadows the built-in: it could + * only be registered while that built-in cannot run on this host. */ + int n = atomic_load(&g_ndyn); + for (int i = 0; i < n; i++) + if (g_dyn[i] && strcmp(g_dyn[i]->name, name) == 0) + return g_dyn[i]; return cst_tool_at(cst_tool_index(name)); } int cst_tool_known(const char *name) { return cst_tool_find(name) != NULL; } +/* --- capability metadata ------------------------------------------------- */ + +const char *const CST_PLAT_DESKTOP[] = {"desktop", NULL}; + +const char *cst_platform_name(void) { + const char *over = getenv("CST_PLATFORM"); + if (over && over[0]) + return over; /* tests, cross checks, a launcher that knows better */ +#if defined(_WIN32) + return "win"; +#elif defined(__APPLE__) + return "macos"; +#elif defined(__ANDROID__) + return getenv("TERMUX_VERSION") ? "termux" : "android"; +#elif defined(__linux__) + return "linux"; +#else + return "other"; +#endif +} + +int cst_tool_platform_ok(const cst_tool_def_t *t) { + if (!t || !t->platforms) + return 1; + const char *cur = cst_platform_name(); + for (int i = 0; t->platforms[i]; i++) { + const char *p = t->platforms[i]; + if (strcmp(p, cur) == 0) + return 1; + if (strcmp(p, "desktop") == 0 && (strcmp(cur, "linux") == 0 || + strcmp(cur, "win") == 0 || + strcmp(cur, "macos") == 0)) + return 1; + /* A Termux host is an Android device, so "android" covers it; the other + * way round is not true: Termux helpers are absent in an app sandbox. */ + if (strcmp(cur, "termux") == 0 && strcmp(p, "android") == 0) + return 1; + } + return 0; +} + +/* Probes are cached by def pointer: built-ins are static, dynamic entries + * keep their address while registered. */ +#define META_CACHE_MAX 256 +static struct { + const cst_tool_def_t *t; + int ok; + char why[128]; +} g_meta[META_CACHE_MAX]; +static int g_nmeta; +static pthread_mutex_t g_meta_mu = PTHREAD_MUTEX_INITIALIZER; + +void cst_tool_meta_reload(void) { + pthread_mutex_lock(&g_meta_mu); + g_nmeta = 0; + pthread_mutex_unlock(&g_meta_mu); +} + +int cst_tool_available(const cst_tool_def_t *t, char *why, size_t why_sz) { + if (why && why_sz) + why[0] = 0; + if (!t) + return -1; + pthread_mutex_lock(&g_meta_mu); + for (int i = 0; i < g_nmeta; i++) { + if (g_meta[i].t != t) + continue; + int ok = g_meta[i].ok; + if (why && why_sz) + snprintf(why, why_sz, "%s", g_meta[i].why); + pthread_mutex_unlock(&g_meta_mu); + return ok; + } + pthread_mutex_unlock(&g_meta_mu); + + char tmp[128] = ""; + int ok = 0; + if (!cst_tool_platform_ok(t)) { + snprintf(tmp, sizeof(tmp), "it exists on %s only and this host is %s", + t->platforms && t->platforms[0] ? t->platforms[0] + : "another platform", + cst_platform_name()); + ok = -1; + } else if (t->available && t->available(tmp, sizeof(tmp)) != 0) { + if (!tmp[0]) + snprintf(tmp, sizeof(tmp), "a service it needs is missing here"); + ok = -1; + } + pthread_mutex_lock(&g_meta_mu); + if (g_nmeta < META_CACHE_MAX) { + g_meta[g_nmeta].t = t; + g_meta[g_nmeta].ok = ok; + snprintf(g_meta[g_nmeta].why, sizeof(g_meta[g_nmeta].why), "%s", tmp); + g_nmeta++; + } + pthread_mutex_unlock(&g_meta_mu); + if (why && why_sz) + snprintf(why, why_sz, "%s", tmp); + return ok; +} + +const char *cst_tool_perm_name(const cst_tool_def_t *t) { + static const char *const class_names[] = {"read", "write", "shell", + "net", "ask", "meta"}; + if (t && t->perm_name && t->perm_name[0]) + return t->perm_name; + int p = t ? (int)t->perm : (int)CST_TP_READ; + return p >= 0 && p < 6 ? class_names[p] : "?"; +} + void cst_tool_sanitize_name(const char *in, char *out, size_t out_sz) { size_t o = 0; for (size_t i = 0; in && in[i] && o + 1 < out_sz && o < 63; i++) { @@ -255,6 +378,7 @@ static void dyn_free(cst_tool_def_t *t) { return; free((char *)t->name); free((char *)t->desc); + free((char *)t->perm_name); free((char *)t->schema_json); free((char *)t->owner); free(t); @@ -266,7 +390,10 @@ int cst_tool_register(const cst_tool_def_t *def) { DYN_LOCK(); int rc = -1; int n = atomic_load(&g_ndyn); - if (n < DYN_MAX && cst_tool_index(def->name) < 0) { + /* The name is free, or held by a tool that cannot run on this host (the + * app's Android clipboard taking over the name of the desktop one). */ + const cst_tool_def_t *taken = cst_tool_find(def->name); + if (n < DYN_MAX && (!taken || cst_tool_available(taken, NULL, 0) != 0)) { cst_tool_def_t *t = calloc(1, sizeof(*t)); if (t) { *t = *def; @@ -274,6 +401,7 @@ int cst_tool_register(const cst_tool_def_t *def) { t->minimal = 0; t->name = dup_or_null(def->name); t->desc = dup_or_null(def->desc ? def->desc : ""); + t->perm_name = dup_or_null(def->perm_name); t->schema_json = dup_or_null(def->schema_json); t->owner = dup_or_null(def->owner); if (t->name && t->desc) { @@ -286,6 +414,8 @@ int cst_tool_register(const cst_tool_def_t *def) { } } DYN_UNLOCK(); + if (rc == 0) + cst_tool_meta_reload(); /* a new entry may shadow an old probe */ return rc; } @@ -306,6 +436,8 @@ int cst_tool_unregister_owner(const char *owner) { g_dyn[i] = NULL; atomic_store(&g_ndyn, k); DYN_UNLOCK(); + if (removed) + cst_tool_meta_reload(); /* drop probes of defs that are now gone */ return removed; } @@ -352,6 +484,10 @@ int cst_tool_is_write(const char *name) { int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t) { if (!t) return 0; + /* A tool this host cannot run is never offered: the prompt, tools[] and the + * /tools panel all go through here. */ + if (cst_tool_available(t, NULL, 0) != 0) + return 0; if (!cfg) return 1; if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal) @@ -628,6 +764,10 @@ int cst_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *call, const cst_tool_def_t *t = cst_tool_find(call->name); if (!t) return tool_set_resultf(r, 1, "error: unknown tool: %.64s", call->name); + char why[160]; + if (cst_tool_available(t, why, sizeof(why)) != 0) + return tool_set_resultf(r, 1, "error: %.64s cannot run here: %.120s", + call->name, why[0] ? why : "not supported"); char err[256]; if (cst_tool_validate(t, call, err, sizeof(err)) != 0) return tool_set_result(r, err, 1); diff --git a/src/webui.c b/src/webui.c index 383a8bf..f5bb0b3 100644 --- a/src/webui.c +++ b/src/webui.c @@ -1699,9 +1699,32 @@ static int h_settings_get(cst_http_conn_t *c, void *ud) { cJSON_AddStringToObject(t, "name", d->name); cJSON_AddStringToObject(t, "perm", (int)d->perm < 6 ? wui_perm_names[d->perm] : "?"); + cJSON_AddStringToObject(t, "perm_name", cst_tool_perm_name(d)); cJSON_AddBoolToObject(t, "on", cst_tool_active(cfg, d)); cJSON_AddBoolToObject(t, "off", cst_tool_disabled(cfg, d->name)); cJSON_AddStringToObject(t, "desc", first); + if (d->platforms) { + cJSON *pl = cJSON_AddArrayToObject(t, "platforms"); + for (int k = 0; pl && d->platforms[k]; k++) + cJSON_AddItemToArray(pl, cJSON_CreateString(d->platforms[k])); + } + char why[160] = ""; + if (cst_tool_available(d, why, sizeof(why)) != 0) { + cJSON_AddBoolToObject(t, "available", 0); + cJSON_AddStringToObject(t, "why", why); + } else { + cJSON_AddBoolToObject(t, "available", 1); + } + if (d->params[0].name) { + cJSON *pa = cJSON_AddArrayToObject(t, "params"); + for (int k = 0; pa && k < CST_TOOL_MAX_PARAMS && d->params[k].name; k++) { + cJSON *p = cJSON_CreateObject(); + cJSON_AddStringToObject(p, "name", d->params[k].name); + cJSON_AddStringToObject(p, "type", d->params[k].type); + cJSON_AddBoolToObject(p, "required", d->params[k].required); + cJSON_AddItemToArray(pa, p); + } + } if (d->owner) cJSON_AddStringToObject(t, "owner", d->owner); cJSON_AddItemToArray(tools, t); diff --git a/tests/test_diff.c b/tests/test_diff.c index 178f038..2555912 100644 --- a/tests/test_diff.c +++ b/tests/test_diff.c @@ -77,10 +77,26 @@ static char *gnu_diff(const char *a, const char *b) { } static int have_gnu_diff(void) { +#ifdef _WIN32 + return 0; /* gnu_diff() has no implementation there */ +#else + /* Android's toybox diff (and busybox's) print the same "@@ -1 +1 @@" for a + * one-line change but group hunks and context their own way, so the layout + * comparisons only make sense against real diffutils. */ + FILE *pp = popen("LC_ALL=C diff --version 2>/dev/null", "r"); + char line[128] = ""; + if (pp) { + if (!fgets(line, sizeof(line), pp)) + line[0] = 0; + pclose(pp); + } + if (!strstr(line, "GNU diffutils")) + return 0; char *o = gnu_diff("x\n", "y\n"); int ok = o && strstr(o, "@@ -1 +1 @@"); free(o); return ok; +#endif } static char *mine(const char *a, const char *b) { diff --git a/tests/test_embed.c b/tests/test_embed.c new file mode 100644 index 0000000..5cdf8d8 --- /dev/null +++ b/tests/test_embed.c @@ -0,0 +1,189 @@ +/* The Qt app embeds the engine in-process: it creates an agent from a + * config, registers its own platform tools with owner "app:*" and dispatches + * them exactly like the model would. No HTTP server, no port, no token and + * no second process -- this is the embedding path app/ (Qt) relies on, kept + * runnable without Qt so it guards the C side on every `make check`. */ + +#define _POSIX_C_SOURCE 200809L + +#include "cJSON.h" +#include "cst_agent.h" +#include "cst_test_util.h" +#include "cst_tools.h" + +#include +#include +#include + +static int nfail; + +#define EXPECT(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ + nfail++; \ + } \ + } while (0) + +/* What a platform tool implemented by the front end looks like on the C + * side: a callback plus whatever the front end needs to keep. */ +struct app_ctx { + int calls; + char last[128]; +}; + +static int app_notify_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r, void *ud) { + (void)ctx; + struct app_ctx *a = ud; + const char *msg = NULL; + for (int i = 0; i < c->nargs; i++) + if (strcmp(c->args[i].key, "message") == 0) + msg = c->args[i].value; + a->calls++; + snprintf(a->last, sizeof(a->last), "%s", msg ? msg : ""); + char buf[256]; + snprintf(buf, sizeof(buf), "notified: %s", msg ? msg : ""); + r->out = strdup(buf); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = 0; + return 0; +} + +static void dispatch_notify(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *call, cst_tool_result_t *r, + struct app_ctx *actx) { + EXPECT(cst_tool_run(ctx, call, r) == 0, "the app tool runs"); + EXPECT(r->out && strstr(r->out, "notified: hi") != NULL, + "its output is the tool result"); + EXPECT(actx->calls == 1 && strcmp(actx->last, "hi") == 0, + "the C callback saw the argument"); + cst_tool_result_free(r); +} + +int main(void) { + char root[1024]; + if (test_tmpdir(root, sizeof(root), "cst-embed") != 0) { + fprintf(stderr, "cannot create a scratch dir\n"); + return 1; + } + + cst_agent_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root); + snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", root); + snprintf(cfg.provider, sizeof(cfg.provider), "deepseek"); + snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash"); + snprintf(cfg.api_base, sizeof(cfg.api_base), "https://api.deepseek.com/v1"); + snprintf(cfg.api_key, sizeof(cfg.api_key), "test-key"); + snprintf(cfg.mode, sizeof(cfg.mode), "general"); + cfg.max_steps = 1; + cfg.headless = 1; + + cst_agent_t *a = cst_agent_create(&cfg); + EXPECT(a != NULL, "the app can create an engine in-process"); + if (!a) { + test_rmrf(root); + return 1; + } + EXPECT(cst_agent_session_path(a) != NULL, "the agent owns its session"); + + /* the front end registers its own tool: android-only, policy "notify" */ + static const char *const ANDROID_ONLY[] = {"android", NULL}; + struct app_ctx actx; + memset(&actx, 0, sizeof(actx)); + cst_tool_def_t def; + memset(&def, 0, sizeof(def)); + def.name = "app_notify"; + def.desc = "Send a system notification (provided by the app)."; + def.perm = CST_TP_ASK; + def.perm_name = "notify"; + def.platforms = ANDROID_ONLY; + def.run_ex = app_notify_run; + def.ud = &actx; + def.owner = "app:test"; + EXPECT(cst_tool_register(&def) == 0, "the app registers a tool in-process"); + + const cst_tool_def_t *t = cst_tool_find("app_notify"); + EXPECT(t != NULL, "the registry knows it"); + if (!t) { + cst_agent_destroy(a); + test_rmrf(root); + return 1; + } + EXPECT(strcmp(cst_tool_perm_name(t), "notify") == 0, + "its policy name survives registration"); + EXPECT(t->run_ex == app_notify_run && t->ud == &actx, + "so does the callback and its userdata"); + + /* on a desktop host it must not be offered (the probe is the only gate) */ + setenv("CST_PLATFORM", "linux", 1); + cst_tool_meta_reload(); + char why[160]; + EXPECT(cst_tool_available(t, why, sizeof(why)) != 0, + "an android-only tool is not available on this host"); + EXPECT(!cst_tool_active(cst_agent_config(a), t), + "the agent's config does not offer it"); + + /* ... and once the host is the phone it is, through the same config */ + setenv("CST_PLATFORM", "android", 1); + cst_tool_meta_reload(); + EXPECT(cst_tool_available(t, why, sizeof(why)) == 0, "available on android"); + EXPECT(cst_tool_active(cst_agent_config(a), t), "and offered there"); + char *names = cst_tools_names(cst_agent_config(a)); + EXPECT(names && strstr(names, "app_notify") != NULL, + "it is part of the active tool set"); + free(names); + char *prompt = cst_tools_prompt(cst_agent_config(a)); + EXPECT(prompt && strstr(prompt, "app_notify") != NULL, + "and the prompt describes it like any built-in"); + free(prompt); + void *schema = cst_tools_schema_cfg(cst_agent_config(a)); + char *sj = schema ? cJSON_PrintUnformatted(schema) : NULL; + EXPECT(sj && strstr(sj, "app_notify") != NULL, "tools[] carries it too"); + free(sj); + if (schema) + cJSON_Delete(schema); + + /* dispatch: the front end's callback is what runs */ + ctl_tool_call_t call; + memset(&call, 0, sizeof(call)); + snprintf(call.name, sizeof(call.name), "app_notify"); + snprintf(call.args[0].key, sizeof(call.args[0].key), "message"); + call.args[0].value = strdup("hi"); + call.args[0].value_len = strlen("hi"); + call.nargs = 1; + cst_tool_result_t r; + cst_tool_ctx_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.workspace_root = root; + ctx.agent = a; + dispatch_notify(&ctx, &call, &r, &actx); + free(call.args[0].value); + + /* a name that is already taken by a tool that *can* run here is refused */ + cst_tool_def_t dup; + memset(&dup, 0, sizeof(dup)); + dup.name = "read_file"; + dup.desc = "not allowed to shadow a working built-in"; + dup.perm = CST_TP_READ; + dup.run_ex = app_notify_run; + dup.owner = "app:test"; + EXPECT(cst_tool_find("read_file")->owner == NULL, "read_file is a built-in"); + EXPECT(cst_tool_register(&dup) != 0, + "a working built-in cannot be shadowed"); + + /* dropping the app's tools leaves the built-in registry untouched */ + EXPECT(cst_tool_unregister_owner("app:test") == 1, "the app unregisters"); + EXPECT(cst_tool_find("app_notify") == NULL, "its tool is gone"); + EXPECT(cst_tool_find("read_file") != NULL, "the built-ins are still there"); + + cst_agent_destroy(a); + test_rmrf(root); + if (nfail) { + fprintf(stderr, "test_embed: %d failed\n", nfail); + return 1; + } + printf("test_embed: OK\n"); + return 0; +} diff --git a/tests/test_llm_body.c b/tests/test_llm_body.c index 1cafa7b..d8decec 100644 --- a/tests/test_llm_body.c +++ b/tests/test_llm_body.c @@ -108,8 +108,13 @@ static void test_tool_calls_and_null_content(void) { EXPECT(cJSON_IsString(tid) && strcmp(tid->valuestring, "c1") == 0, "tool_call_id"); const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools"); - EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) >= 4, + /* The exact set depends on the host: a tool whose platform probe fails is + * offered nowhere, so look for a core tool instead of counting. */ + char *tj = tools ? cJSON_PrintUnformatted(tools) : NULL; + EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) >= 4 && tj && + strstr(tj, "read_file") != NULL, "tools[] schema present"); + free(tj); const cJSON *choice = cJSON_GetObjectItemCaseSensitive(j, "tool_choice"); EXPECT(cJSON_IsString(choice) && strcmp(choice->valuestring, "auto") == 0, "tool_choice auto"); diff --git a/tests/test_registry.c b/tests/test_registry.c index 49ffbf0..d2ed09f 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -138,7 +138,13 @@ int main(void) { } cJSON *all = cst_tools_schema_cfg(&cfg); - EXPECT(cJSON_GetArraySize(all) == cst_tool_count(), "default: every tool"); + /* Every tool the host can actually run is offered in the default profile; + * one whose platform probe fails (the clipboard without a helper) is not. */ + int usable = 0; + for (int i = 0; i < cst_tool_count(); i++) + if (cst_tool_available(cst_tool_at(i), NULL, 0) == 0) + usable++; + EXPECT(cJSON_GetArraySize(all) == usable, "default: every usable tool"); EXPECT(schema_has(all, "edit_file") && schema_has(all, "grep") && schema_has(all, "web_fetch"), "default schema has new tools"); diff --git a/tests/test_tool_meta.c b/tests/test_tool_meta.c new file mode 100644 index 0000000..59b9abd --- /dev/null +++ b/tests/test_tool_meta.c @@ -0,0 +1,196 @@ +/* Tool capability metadata: platform declarations, availability probes and + * the shadowing rule (an app-provided tool may take over a name whose + * built-in cannot run on this host). */ + +#define _POSIX_C_SOURCE 200809L + +#include "cst_tools.h" + +#include +#include +#include + +static int nfail; + +#define EXPECT(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ + nfail++; \ + } \ + } while (0) + +static int echo_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + (void)ctx; + (void)c; + r->out = strdup("ran"); + r->out_len = r->out ? strlen(r->out) : 0; + r->exit_code = 0; + return 0; +} + +static int probe_fail(char *why, size_t why_sz) { + snprintf(why, why_sz, "no notification daemon on this host"); + return -1; +} + +static const char *const ANDROID_ONLY[] = {"android", NULL}; + +static void test_platform_gating(void) { + cst_tool_def_t d; + memset(&d, 0, sizeof(d)); + d.name = "meta_android_only"; + d.desc = "Android-only test tool."; + d.perm = CST_TP_ASK; + d.perm_name = "notify"; + d.platforms = ANDROID_ONLY; + d.run = echo_run; + d.owner = "test:meta"; + EXPECT(cst_tool_register(&d) == 0, "register an android-only tool"); + const cst_tool_def_t *t = cst_tool_find("meta_android_only"); + EXPECT(t != NULL, "it is in the registry"); + if (!t) + return; + EXPECT(strcmp(cst_tool_perm_name(t), "notify") == 0, "perm_name is kept"); + + setenv("CST_PLATFORM", "linux", 1); + cst_tool_meta_reload(); + char why[160]; + EXPECT(cst_tool_available(t, why, sizeof(why)) != 0, + "an android-only tool is unavailable on linux"); + EXPECT(strstr(why, "android") != NULL, "and says what it is for"); + EXPECT(!cst_tool_active(NULL, t), "so it is not offered at all"); + + /* the model must not be able to force it either: the call is answered with + * an error result (run() reports 0 for a tool-level failure, the exit code + * and the text carry it) */ + ctl_tool_call_t call; + memset(&call, 0, sizeof(call)); + snprintf(call.name, sizeof(call.name), "meta_android_only"); + cst_tool_result_t r; + cst_tool_ctx_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.workspace_root = "."; + EXPECT(cst_tool_run(&ctx, &call, &r) == 0 && r.out && r.exit_code != 0 && + strstr(r.out, "cannot run here") != NULL, + "calling it anyway is refused with a reason"); + EXPECT(strstr(r.out, "android") != NULL, "the reason names the platform"); + cst_tool_result_free(&r); + + setenv("CST_PLATFORM", "android", 1); + cst_tool_meta_reload(); + EXPECT(cst_tool_available(t, why, sizeof(why)) == 0, + "available once the host is an android device"); + EXPECT(cst_tool_active(NULL, t), "and offered to the model"); + char *names = cst_tools_names(NULL); + EXPECT(names && strstr(names, "meta_android_only") != NULL, + "it shows up in the active tool names"); + free(names); + + /* a Termux host counts as android; "termux" is not matched by "android" */ + setenv("CST_PLATFORM", "termux", 1); + cst_tool_meta_reload(); + EXPECT(cst_tool_available(t, why, sizeof(why)) == 0, + "a Termux host is an android device"); + + /* the desktop pseudo-platform covers linux / win / macos */ + cst_tool_def_t desk; + memset(&desk, 0, sizeof(desk)); + desk.name = "meta_desktop"; + desk.desc = "Desktop-only test tool."; + desk.perm = CST_TP_READ; + desk.platforms = CST_PLAT_DESKTOP; + desk.run = echo_run; + desk.owner = "test:meta"; + EXPECT(cst_tool_register(&desk) == 0, "register a desktop-only tool"); + const cst_tool_def_t *dt = cst_tool_find("meta_desktop"); + setenv("CST_PLATFORM", "linux", 1); + cst_tool_meta_reload(); + EXPECT(dt && cst_tool_available(dt, why, sizeof(why)) == 0, "desktop on linux"); + setenv("CST_PLATFORM", "android", 1); + cst_tool_meta_reload(); + EXPECT(dt && cst_tool_available(dt, why, sizeof(why)) != 0, + "not on android"); +} + +static void test_probe(void) { + cst_tool_def_t d; + memset(&d, 0, sizeof(d)); + d.name = "meta_no_daemon"; + d.desc = "A tool whose service is missing."; + d.perm = CST_TP_ASK; + d.available = probe_fail; + d.run = echo_run; + d.owner = "test:meta"; + EXPECT(cst_tool_register(&d) == 0, "register a tool with a failing probe"); + const cst_tool_def_t *t = cst_tool_find("meta_no_daemon"); + char why[160]; + setenv("CST_PLATFORM", "linux", 1); + cst_tool_meta_reload(); + EXPECT(t && cst_tool_available(t, why, sizeof(why)) != 0, "probe says no"); + EXPECT(strstr(why, "notification daemon") != NULL, + "the probe's own reason is passed on"); + EXPECT(t && !cst_tool_active(NULL, t), "and it is not offered"); + /* the cache answers the same way, and a reload re-probes */ + EXPECT(t && cst_tool_available(t, NULL, 0) != 0, "cached answer"); + cst_tool_meta_reload(); + EXPECT(t && cst_tool_available(t, why, sizeof(why)) != 0, "re-probed answer"); +} + +static void test_shadowing(void) { + /* A tool the app provides may take a built-in name when that built-in + * cannot run here; the app's entry then shadows it. */ + cst_tool_def_t d; + memset(&d, 0, sizeof(d)); + d.name = "clipboard_read"; + d.desc = "Read the clipboard (provided by the app)."; + d.perm = CST_TP_READ; + d.perm_name = "read"; + d.run = echo_run; + d.owner = "app:test"; + int registered = cst_tool_register(&d) == 0; + const cst_tool_def_t *t = cst_tool_find("clipboard_read"); + EXPECT(t != NULL, "clipboard_read stays findable either way"); + if (registered) { + EXPECT(t && t->owner && strcmp(t->owner, "app:test") == 0, + "the app's tool shadows the unavailable built-in"); + EXPECT(t && cst_tool_active(NULL, t), "and it is offered"); + EXPECT(cst_tool_unregister_owner("app:test") == 1, "dropping it again"); + t = cst_tool_find("clipboard_read"); + EXPECT(t && (t->owner == NULL || strcmp(t->owner, "app:test") != 0), + "the built-in is back"); + } else { + EXPECT(t && cst_tool_available(t, NULL, 0) == 0, + "a name is only taken by a tool that can run here"); + } +} + +static void test_perm_names(void) { + const cst_tool_def_t *t = cst_tool_find("delete_file"); + EXPECT(t && strcmp(cst_tool_perm_name(t), "delete") == 0, + "delete_file is a delete, not just a write"); + t = cst_tool_find("read_file"); + EXPECT(t && strcmp(cst_tool_perm_name(t), "read") == 0, "read_file is read"); + t = cst_tool_find("run_shell"); + EXPECT(t && strcmp(cst_tool_perm_name(t), "shell") == 0, "carried by class"); + t = cst_tool_find("clipboard_write"); + EXPECT(t && strcmp(cst_tool_perm_name(t), "write") == 0, "clipboard_write"); + EXPECT(cst_platform_name() != NULL && cst_platform_name()[0], + "host platform has a name"); +} + +int main(void) { + test_platform_gating(); + test_probe(); + test_shadowing(); + test_perm_names(); + cst_tool_unregister_owner("test:meta"); + cst_tool_unregister_owner("app:test"); + if (nfail) { + fprintf(stderr, "test_tool_meta: %d failed\n", nfail); + return 1; + } + printf("test_tool_meta: OK\n"); + return 0; +}