From de9ee28773c5e7362d35e31d8ddad27a5c7ceeef Mon Sep 17 00:00:00 2001 From: WHC Date: Wed, 28 Oct 2026 23:06:51 +0800 Subject: [PATCH] feat: password Realm, single-chair login, shared parliament context (0.9.12) Derive Realm keys from a user password (Argon2id, no private key on disk). Login claims the sole chair with handover; peers share parliament context; --new-project clears it. CLI peer/discover helpers and Docker alpine recipe included; headless empty-answer retries raised for unattended runs. Co-authored-by: Cursor --- .dockerignore | 17 ++ CMakeLists.txt | 4 +- Makefile | 3 +- include/cst_agent.h | 2 + include/cst_parliament.h | 51 +++++ include/cst_parliament_cli.h | 26 +++ include/cst_realm.h | 15 ++ include/cst_version.h | 2 +- scripts/Dockerfile.parliament | 14 ++ scripts/docker_parliament_test.sh | 59 ++++++ src/agent_loop.c | 18 +- src/main.c | 135 ++++++++++++++ src/parliament.c | 297 ++++++++++++++++++++++++++++++ src/parliament_cli.c | 216 ++++++++++++++++++++++ src/realm.c | 173 +++++++++++++++++ src/settings.c | 4 + src/subagent.c | 15 ++ tests/test_parliament.c | 58 ++++++ 18 files changed, 1101 insertions(+), 8 deletions(-) create mode 100644 .dockerignore create mode 100644 include/cst_parliament.h create mode 100644 include/cst_parliament_cli.h create mode 100644 scripts/Dockerfile.parliament create mode 100755 scripts/docker_parliament_test.sh create mode 100644 src/parliament.c create mode 100644 src/parliament_cli.c create mode 100644 tests/test_parliament.c diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..326b2a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +result +build/ +build-*/ +dist/ +third_party/ +backups/ +reports/ +*.o +*.a +cst-agent +tests/test_* +!tests/test_*.c +app/ +.cursor/ +*.apk +*.tar.gz diff --git a/CMakeLists.txt b/CMakeLists.txt index 6fbbeb7..1a351cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -124,6 +124,8 @@ set(CST_CORE_SOURCES src/realm.c src/discover.c src/peer_auth.c + src/parliament.c + src/parliament_cli.c src/attach.c src/clipboard.c src/pdf_text.c @@ -389,7 +391,7 @@ 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_runtime test_realm test_discover - test_peer_auth test_lowil + test_peer_auth test_parliament test_lowil test_win_utf8 test_os_dir test_tmpdir test_tool_meta test_embed) if(CST_BUILD_WEBUI) diff --git a/Makefile b/Makefile index 56615b4..4bf76b6 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,7 @@ CORE_SRC = \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \ src/pdf_text.c \ src/session.c src/runtime.c src/worktree.c src/realm.c src/discover.c \ - src/peer_auth.c \ + src/peer_auth.c src/parliament.c src/parliament_cli.c \ src/tools/tools.c \ src/tools/patch.c \ src/tools/registry.c \ @@ -177,6 +177,7 @@ TESTS = \ tests/test_realm \ tests/test_discover \ tests/test_peer_auth \ + tests/test_parliament \ tests/test_lowil \ tests/test_win_utf8 \ tests/test_os_dir \ diff --git a/include/cst_agent.h b/include/cst_agent.h index f0a9055..fd4e8bf 100644 --- a/include/cst_agent.h +++ b/include/cst_agent.h @@ -497,6 +497,8 @@ typedef struct { char effort[16]; char mode[16]; /* coding | general, "" = not chosen yet */ char approval[8]; /* "" = not chosen yet */ + /* legacy_workers | parliament — parliament replaces in-process subagents. */ + char collaboration[24]; int show_thinking; /* TUI: Thought blocks start expanded */ /* workspaces whose .agent/plugins and .agent/mcp.json may run */ int ntrusted; diff --git a/include/cst_parliament.h b/include/cst_parliament.h new file mode 100644 index 0000000..e76bdb4 --- /dev/null +++ b/include/cst_parliament.h @@ -0,0 +1,51 @@ +#ifndef CST_PARLIAMENT_H +#define CST_PARLIAMENT_H + +#include "cst_realm.h" + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Parliament is one shared whole across agents. Password unlock claims the + * single chair on the logging-in device; peers sync the same context. A new + * project replaces all parliament context. */ + +typedef struct { + char parliament_id[64]; + char project_id[64]; + char chair_instance[64]; + char chair_device[128]; + unsigned long revision; + char status[32]; /* idle | open | dissolved */ +} cst_parliament_meta_t; + +int cst_parliament_path(char *out, size_t n); +int cst_parliament_load(cst_parliament_meta_t *m); +int cst_parliament_save(const cst_parliament_meta_t *m); + +/* Login: unlock Realm with password, enroll/ensure instance, claim chair, + * return handover summary (malloc'd) or NULL. */ +char *cst_parliament_login(const char *password, const char *instance_id, + const char *device_label, char *err, size_t err_sz); + +/* Only one chair: previous chair is demoted; handover notes left in meta. */ +int cst_parliament_claim_chair(const char *instance_id, const char *device_label); + +/* Probe local environment as device-private context (malloc'd text). */ +char *cst_parliament_device_context(void); + +/* New project: wipe shared parliament context; keep Realm keys. */ +int cst_parliament_new_project(const char *project_id); + +/* Append/replace synchronized context blob (all agents share this file). */ +int cst_parliament_context_put(const char *text); +char *cst_parliament_context_get(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_parliament_cli.h b/include/cst_parliament_cli.h new file mode 100644 index 0000000..165963d --- /dev/null +++ b/include/cst_parliament_cli.h @@ -0,0 +1,26 @@ +#ifndef CST_PARLIAMENT_CLI_H +#define CST_PARLIAMENT_CLI_H + +#ifdef __cplusplus +extern "C" { +#endif + +/* Standalone parliament/Realm ops for Docker and unattended smoke tests. + * Returns a process exit code (0 = success). */ +int cst_parliament_cli_realm_new(void); +int cst_parliament_cli_realm_password_init(const char *password); +int cst_parliament_cli_login(const char *password, const char *instance_id, + const char *device_label); +int cst_parliament_cli_new_project(const char *project_id); +int cst_parliament_cli_realm_enroll(const char *instance_id, const char *label); +int cst_parliament_cli_peer_listen(int port, const char *instance_id); +int cst_parliament_cli_peer_dial(const char *host, int port, + const char *instance_id); +int cst_parliament_cli_discover(const char *instance_id, int peer_port, + int wait_ms); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/include/cst_realm.h b/include/cst_realm.h index 82bfe16..6154db7 100644 --- a/include/cst_realm.h +++ b/include/cst_realm.h @@ -36,6 +36,15 @@ int cst_realm_load(const char *dir, cst_realm_keys_t *out); /* 32 hex chars (blake2b-128 of pk). */ int cst_realm_fingerprint(const uint8_t pk[32], char out[33]); +/* Password-string Realm: Argon2id(password, salt) → seed. Private key is not + * stored; only salt + public.key live on disk. Unlock derives sk in memory. */ +int cst_realm_derive_password(cst_realm_keys_t *out, const char *password, + const uint8_t salt[16]); +int cst_realm_init_password(const char *dir, const char *password); +int cst_realm_unlock_password(const char *dir, const char *password, + cst_realm_keys_t *out); +int cst_realm_is_password_mode(const char *dir); + int cst_realm_enroll(const cst_realm_keys_t *realm, const char *instance_id, const char *label, int64_t not_after, cst_realm_cert_t *cert, uint8_t instance_seed[32], uint8_t instance_sk[64]); @@ -52,6 +61,12 @@ int cst_realm_challenge_check(const cst_realm_cert_t *cert, const void *chal, int cst_realm_revoke_add(const char *dir, const char *instance_id); int cst_realm_revoked(const char *dir, const char *instance_id); +/* Persist an enrolled instance under /instances//. */ +int cst_realm_instance_save(const char *instance_id, const cst_realm_cert_t *cert, + const uint8_t instance_seed[32]); +int cst_realm_instance_load(const char *instance_id, cst_realm_cert_t *cert, + uint8_t instance_sk[64]); + #ifdef __cplusplus } #endif diff --git a/include/cst_version.h b/include/cst_version.h index 25d3cd3..d9ce171 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 11 +#define CST_VERSION_PATCH 12 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/Dockerfile.parliament b/scripts/Dockerfile.parliament new file mode 100644 index 0000000..5c620e0 --- /dev/null +++ b/scripts/Dockerfile.parliament @@ -0,0 +1,14 @@ +# Build inside Alpine so the binary does not depend on the host Nix store. +FROM alpine:latest AS build +RUN apk add --no-cache build-base curl-dev ncurses-dev pkgconf python3 +WORKDIR /src +COPY . . +# Skip WebUI asset embedding quirks in minimal images if needed; keep default on. +RUN make -j"$(nproc)" cst-agent + +FROM alpine:latest +RUN apk add --no-cache libcurl ncurses-libs ca-certificates +COPY --from=build /src/cst-agent /usr/local/bin/cst-agent +ENV XDG_CONFIG_HOME=/realm-config +WORKDIR /work +ENTRYPOINT ["cst-agent"] diff --git a/scripts/docker_parliament_test.sh b/scripts/docker_parliament_test.sh new file mode 100755 index 0000000..3bf19ae --- /dev/null +++ b/scripts/docker_parliament_test.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Real Docker smoke: shared Realm volume, chair listens, member dials. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IMG="${PARLIAMENT_IMAGE:-cst-parliament:local}" +NET="${PARLIAMENT_NET:-cst-parliament-net}" +VOL="${PARLIAMENT_VOL:-cst-parliament-realm}" +LISTEN_PORT=9454 + +cd "$ROOT" +echo "== docker build (alpine) ==" +docker build -f scripts/Dockerfile.parliament -t "$IMG" . + +docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" +docker volume inspect "$VOL" >/dev/null 2>&1 || docker volume create "$VOL" + +cleanup() { + docker rm -f cst-parl-a cst-parl-b >/dev/null 2>&1 || true +} +trap cleanup EXIT +cleanup +docker volume rm "$VOL" >/dev/null 2>&1 || true +docker volume create "$VOL" >/dev/null + +echo "== realm + enroll on shared volume ==" +docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" --realm-new +docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" \ + --realm-enroll chair-a --realm-label chair +docker run --rm -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config "$IMG" \ + --realm-enroll member-b --realm-label member + +echo "== chair listens ==" +docker run -d --name cst-parl-a --network "$NET" \ + -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config \ + "$IMG" --peer-listen="$LISTEN_PORT" --instance chair-a + +for i in $(seq 1 50); do + if docker logs cst-parl-a 2>&1 | grep -q "peer_listen port="; then + break + fi + sleep 0.2 +done +docker logs cst-parl-a 2>&1 | tail -8 + +echo "== member dials ==" +docker run --rm --name cst-parl-b --network "$NET" \ + -v "$VOL:/realm-config" -e XDG_CONFIG_HOME=/realm-config \ + "$IMG" --peer-dial="cst-parl-a:${LISTEN_PORT}" --instance member-b + +echo "== chair result ==" +for i in $(seq 1 50); do + if docker logs cst-parl-a 2>&1 | grep -q "peer_ok"; then + break + fi + sleep 0.2 +done +docker logs cst-parl-a 2>&1 | grep peer_ok + +echo "docker_parliament_test: OK" diff --git a/src/agent_loop.c b/src/agent_loop.c index ed3f021..13048dd 100644 --- a/src/agent_loop.c +++ b/src/agent_loop.c @@ -3251,11 +3251,19 @@ static int run_turn_once(cst_agent_t *a, const char *user_text, } turn_free(&t); - if (empty_retries < 2 && step + 1 < a->cfg.max_steps) { - empty_retries++; - nudge = NUDGE_EMPTY; - hook_status(&t, "empty response — retrying..."); - continue; + /* Headless / unattended turns allow more empty-answer retries so a + * flaky gateway does not end the turn blank. CST_EMPTY_RETRIES overrides. */ + { + int empty_cap = a->cfg.headless ? 4 : 2; + const char *env_er = getenv("CST_EMPTY_RETRIES"); + if (env_er && atoi(env_er) > 0) + empty_cap = atoi(env_er); + if (empty_retries < empty_cap && step + 1 < a->cfg.max_steps) { + empty_retries++; + nudge = NUDGE_EMPTY; + hook_status(&t, "empty response — retrying..."); + continue; + } } /* Last resort: surface the latest tool output so the turn isn't blank. */ if (tools_ran) { diff --git a/src/main.c b/src/main.c index 79e6d20..8a3cfcb 100644 --- a/src/main.c +++ b/src/main.c @@ -1,6 +1,7 @@ #include "cst_agent.h" #include "cst_ext.h" #include "cst_models.h" +#include "cst_parliament_cli.h" #include "cst_tools.h" #include "cst_version.h" #include "cst_webui.h" @@ -47,6 +48,15 @@ static void usage(const char *argv0) { " -n, --max-steps N model calls per turn (30)\n" " --orchestrate delegate edits and shell work to role workers\n" " --no-orchestrate override workspace orchestration setting\n" + " --realm-new create Realm file keys (legacy/dev)\n" + " --realm-password-init create password-derived Realm (no sk on disk)\n" + " --login unlock with password; this device becomes chair\n" + " --new-project ID replace all parliament context for a new project\n" + " --realm-enroll ID enroll this instance under the Realm\n" + " --realm-label S display label for enroll/login\n" + " --peer-listen [N] accept one Realm peer handshake (port N)\n" + " --peer-dial H:N dial host:port and complete handshake\n" + " --discover-wait MS advertise and list same-Realm peers\n" " -H, --headless -p TEXT run one prompt without the TUI\n" " --web serve the UI over HTTP on 127.0.0.1:8787\n" " --web-port N port (default 8787, 0 = pick a free one)\n" @@ -363,6 +373,12 @@ int main(int argc, char **argv) { const char *prompt = NULL; int new_session = 0, do_list = 0, do_migrate = 0; const char *resume_id = NULL; + int do_realm_new = 0, do_realm_enroll = 0, do_peer_listen = 0, do_peer_dial = 0, + do_discover = 0, do_realm_pw_init = 0, do_login = 0, do_new_project = 0; + const char *realm_enroll_id = NULL, *realm_label = NULL; + const char *peer_dial_spec = NULL, *parliament_instance = NULL; + const char *new_project_id = NULL; + int peer_listen_port = 0, discover_wait_ms = 3000, discover_peer_port = 9100; /* WebUI (--web): a second front end on the same engine. */ int web_mode = 0; cst_webui_opts_t web; @@ -412,6 +428,17 @@ int main(int argc, char **argv) { {"web-open", no_argument, 0, 0x106}, {"orchestrate", no_argument, 0, 0x108}, {"no-orchestrate", no_argument, 0, 0x109}, + {"realm-new", no_argument, 0, 0x110}, + {"realm-enroll", required_argument, 0, 0x111}, + {"realm-label", required_argument, 0, 0x112}, + {"peer-listen", optional_argument, 0, 0x113}, + {"peer-dial", required_argument, 0, 0x114}, + {"discover-wait", required_argument, 0, 0x115}, + {"instance", required_argument, 0, 0x116}, + {"peer-port", required_argument, 0, 0x117}, + {"realm-password-init", no_argument, 0, 0x118}, + {"login", no_argument, 0, 0x119}, + {"new-project", required_argument, 0, 0x11a}, {0, 0, 0, 0}}; int c; while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:M:", @@ -423,6 +450,48 @@ int main(int argc, char **argv) { case 0x109: cfg.orchestrate_override = -1; break; + case 0x110: + do_realm_new = 1; + break; + case 0x111: + do_realm_enroll = 1; + realm_enroll_id = optarg; + break; + case 0x112: + realm_label = optarg; + break; + case 0x113: + do_peer_listen = 1; + peer_listen_port = optarg && optarg[0] ? atoi(optarg) : 0; + break; + case 0x114: + do_peer_dial = 1; + peer_dial_spec = optarg; + break; + case 0x115: + do_discover = 1; + discover_wait_ms = atoi(optarg); + if (discover_wait_ms <= 0) + discover_wait_ms = 3000; + break; + case 0x116: + parliament_instance = optarg; + break; + case 0x117: + discover_peer_port = atoi(optarg); + if (discover_peer_port <= 0) + discover_peer_port = 9100; + break; + case 0x118: + do_realm_pw_init = 1; + break; + case 0x119: + do_login = 1; + break; + case 0x11a: + do_new_project = 1; + new_project_id = optarg; + break; case 'w': snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", optarg); break; @@ -580,6 +649,72 @@ int main(int argc, char **argv) { return rc; } + if (do_realm_new) { + int rc = cst_parliament_cli_realm_new(); + curl_global_cleanup(); + return rc; + } + if (do_realm_pw_init) { + const char *pw = getenv("CST_REALM_PASSWORD"); + int rc = cst_parliament_cli_realm_password_init(pw); + curl_global_cleanup(); + return rc; + } + if (do_login) { + const char *pw = getenv("CST_REALM_PASSWORD"); + const char *iid = parliament_instance ? parliament_instance + : getenv("CST_INSTANCE_ID"); + if (!iid || !iid[0]) + iid = "chair-local"; + int rc = cst_parliament_cli_login(pw, iid, realm_label); + curl_global_cleanup(); + return rc; + } + if (do_new_project) { + int rc = cst_parliament_cli_new_project(new_project_id); + curl_global_cleanup(); + return rc; + } + if (do_realm_enroll) { + int rc = cst_parliament_cli_realm_enroll(realm_enroll_id, realm_label); + curl_global_cleanup(); + return rc; + } + if (do_peer_listen) { + const char *iid = parliament_instance ? parliament_instance : realm_enroll_id; + if (!iid) + iid = getenv("CST_INSTANCE_ID"); + int rc = cst_parliament_cli_peer_listen(peer_listen_port, iid); + curl_global_cleanup(); + return rc; + } + if (do_peer_dial) { + const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID"); + char host[256]; + int port = 0; + host[0] = 0; + if (peer_dial_spec) { + const char *colon = strrchr(peer_dial_spec, ':'); + if (colon && colon > peer_dial_spec) { + size_t hl = (size_t)(colon - peer_dial_spec); + if (hl >= sizeof(host)) + hl = sizeof(host) - 1; + memcpy(host, peer_dial_spec, hl); + host[hl] = 0; + port = atoi(colon + 1); + } + } + int rc = cst_parliament_cli_peer_dial(host, port, iid); + curl_global_cleanup(); + return rc; + } + if (do_discover) { + const char *iid = parliament_instance ? parliament_instance : getenv("CST_INSTANCE_ID"); + int rc = cst_parliament_cli_discover(iid, discover_peer_port, discover_wait_ms); + curl_global_cleanup(); + return rc; + } + if (do_list) { int rc = list_sessions(cfg.workspace_root); curl_global_cleanup(); diff --git a/src/parliament.c b/src/parliament.c new file mode 100644 index 0000000..3e749f2 --- /dev/null +++ b/src/parliament.c @@ -0,0 +1,297 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif +#include "cst_parliament.h" +#include "cJSON.h" +#include "cst_util.h" +#include "monocypher.h" +#include "os_compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +int cst_parliament_path(char *out, size_t n) { + char dir[OS_PATH_MAX]; + if (cst_realm_dir(dir, sizeof(dir)) != 0) + return -1; + if (os_mkdir_p(dir, 0700) != 0) + return -1; + return os_path_join(out, n, dir, "parliament.json"); +} + +static int context_path(char *out, size_t n) { + char dir[OS_PATH_MAX]; + if (cst_realm_dir(dir, sizeof(dir)) != 0) + return -1; + return os_path_join(out, n, dir, "parliament.context"); +} + +int cst_parliament_load(cst_parliament_meta_t *m) { + if (!m) + return -1; + memset(m, 0, sizeof(*m)); + char path[OS_PATH_MAX]; + if (cst_parliament_path(path, sizeof(path)) != 0) + return -1; + FILE *f = fopen(path, "rb"); + if (!f) { + snprintf(m->status, sizeof(m->status), "%s", "idle"); + return 0; + } + cst_buf_t b = {0}; + char chunk[4096]; + size_t n; + while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 256 * 1024) + cst_buf_append(&b, chunk, n); + fclose(f); + cJSON *o = cJSON_Parse(b.buf ? b.buf : "{}"); + cst_buf_free(&b); + if (!o) + return -1; + const cJSON *v; + v = cJSON_GetObjectItemCaseSensitive(o, "parliament_id"); + if (cJSON_IsString(v)) + snprintf(m->parliament_id, sizeof(m->parliament_id), "%s", v->valuestring); + v = cJSON_GetObjectItemCaseSensitive(o, "project_id"); + if (cJSON_IsString(v)) + snprintf(m->project_id, sizeof(m->project_id), "%s", v->valuestring); + v = cJSON_GetObjectItemCaseSensitive(o, "chair_instance"); + if (cJSON_IsString(v)) + snprintf(m->chair_instance, sizeof(m->chair_instance), "%s", + v->valuestring); + v = cJSON_GetObjectItemCaseSensitive(o, "chair_device"); + if (cJSON_IsString(v)) + snprintf(m->chair_device, sizeof(m->chair_device), "%s", v->valuestring); + v = cJSON_GetObjectItemCaseSensitive(o, "revision"); + if (cJSON_IsNumber(v)) + m->revision = (unsigned long)v->valuedouble; + v = cJSON_GetObjectItemCaseSensitive(o, "status"); + if (cJSON_IsString(v)) + snprintf(m->status, sizeof(m->status), "%s", v->valuestring); + else + snprintf(m->status, sizeof(m->status), "%s", "idle"); + cJSON_Delete(o); + return 0; +} + +int cst_parliament_save(const cst_parliament_meta_t *m) { + if (!m) + return -1; + char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8]; + if (cst_parliament_path(path, sizeof(path)) != 0) + return -1; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) + return -1; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "parliament_id", m->parliament_id); + cJSON_AddStringToObject(o, "project_id", m->project_id); + cJSON_AddStringToObject(o, "chair_instance", m->chair_instance); + cJSON_AddStringToObject(o, "chair_device", m->chair_device); + cJSON_AddNumberToObject(o, "revision", (double)m->revision); + cJSON_AddStringToObject(o, "status", m->status); + char *txt = cJSON_PrintUnformatted(o); + cJSON_Delete(o); + if (!txt) + return -1; + FILE *f = fopen(tmp, "wb"); + int ok = 0; + if (f) { + ok = fputs(txt, f) >= 0 && fputc('\n', f) >= 0; + if (fclose(f) != 0) + ok = 0; + } + free(txt); + if (!ok) { + remove(tmp); + return -1; + } + if (os_rename_replace(tmp, path) != 0) { + remove(tmp); + return -1; + } + return 0; +} + +char *cst_parliament_device_context(void) { + cst_buf_t b = {0}; + char host[128] = "unknown", cwd[OS_PATH_MAX] = "."; +#ifdef _WIN32 + DWORD n = sizeof(host); + GetComputerNameA(host, &n); + _getcwd(cwd, (int)sizeof(cwd)); + cst_buf_printf(&b, "os=windows\nhost=%s\ncwd=%s\npid=%d\n", host, cwd, + (int)GetCurrentProcessId()); +#else + gethostname(host, sizeof(host)); + if (!getcwd(cwd, sizeof(cwd))) + snprintf(cwd, sizeof(cwd), "."); + cst_buf_printf(&b, "os=unix\nhost=%s\ncwd=%s\npid=%d\n", host, cwd, + (int)getpid()); +#ifdef __linux__ + cst_buf_puts(&b, "platform=linux\n"); +#elif defined(__APPLE__) + cst_buf_puts(&b, "platform=macos\n"); +#endif +#endif + return cst_buf_steal(&b); +} + +int cst_parliament_claim_chair(const char *instance_id, + const char *device_label) { + if (!instance_id || !instance_id[0]) + return -1; + cst_parliament_meta_t m; + if (cst_parliament_load(&m) != 0) + return -1; + if (!m.parliament_id[0]) { + snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld", + (long)time(NULL)); + snprintf(m.status, sizeof(m.status), "%s", "open"); + } + /* Single chair: logging-in device always takes the chair (user will). */ + snprintf(m.chair_instance, sizeof(m.chair_instance), "%s", instance_id); + snprintf(m.chair_device, sizeof(m.chair_device), "%s", + device_label ? device_label : ""); + m.revision++; + snprintf(m.status, sizeof(m.status), "%s", "open"); + return cst_parliament_save(&m); +} + +int cst_parliament_context_put(const char *text) { + char path[OS_PATH_MAX], tmp[OS_PATH_MAX + 8]; + if (context_path(path, sizeof(path)) != 0) + return -1; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) + return -1; + FILE *f = fopen(tmp, "wb"); + if (!f) + return -1; + int ok = fputs(text ? text : "", f) >= 0; + if (fclose(f) != 0) + ok = 0; + if (!ok) { + remove(tmp); + return -1; + } + if (os_rename_replace(tmp, path) != 0) { + remove(tmp); + return -1; + } + cst_parliament_meta_t m; + if (cst_parliament_load(&m) == 0) { + m.revision++; + cst_parliament_save(&m); + } + return 0; +} + +char *cst_parliament_context_get(void) { + char path[OS_PATH_MAX]; + if (context_path(path, sizeof(path)) != 0) + return strdup(""); + FILE *f = fopen(path, "rb"); + if (!f) + return strdup(""); + cst_buf_t b = {0}; + char chunk[4096]; + size_t n; + while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 2 * 1024 * 1024) + cst_buf_append(&b, chunk, n); + fclose(f); + return b.buf ? cst_buf_steal(&b) : strdup(""); +} + +int cst_parliament_new_project(const char *project_id) { + cst_parliament_meta_t m; + memset(&m, 0, sizeof(m)); + snprintf(m.parliament_id, sizeof(m.parliament_id), "parl-%ld", + (long)time(NULL)); + snprintf(m.project_id, sizeof(m.project_id), "%s", + project_id && project_id[0] ? project_id : "default"); + snprintf(m.status, sizeof(m.status), "%s", "open"); + m.revision = 1; + /* Keep chair empty until next login claims it. */ + if (cst_parliament_save(&m) != 0) + return -1; + return cst_parliament_context_put(""); +} + +char *cst_parliament_login(const char *password, const char *instance_id, + const char *device_label, char *err, size_t err_sz) { + if (err && err_sz) + err[0] = 0; + if (!password || !password[0] || !instance_id || !instance_id[0]) { + if (err && err_sz) + snprintf(err, err_sz, "password and instance id required"); + return NULL; + } + char dir[OS_PATH_MAX]; + if (cst_realm_dir(dir, sizeof(dir)) != 0) { + if (err && err_sz) + snprintf(err, err_sz, "realm dir missing"); + return NULL; + } + if (!cst_realm_is_password_mode(dir)) { + if (err && err_sz) + snprintf(err, err_sz, "realm is not password mode; run --realm-password-init"); + return NULL; + } + cst_realm_keys_t keys; + if (cst_realm_unlock_password(dir, password, &keys) != 0) { + if (err && err_sz) + snprintf(err, err_sz, "login failed (bad password)"); + return NULL; + } + /* Ensure this device has an enrolled instance cert. */ + cst_realm_cert_t cert; + uint8_t sk[64]; + if (cst_realm_instance_load(instance_id, &cert, sk) != 0) { + uint8_t seed[32]; + if (cst_realm_enroll(&keys, instance_id, device_label, 0, &cert, seed, sk) != + 0 || + cst_realm_instance_save(instance_id, &cert, seed) != 0) { + if (err && err_sz) + snprintf(err, err_sz, "enroll instance failed"); + crypto_wipe(keys.seed, sizeof(keys.seed)); + crypto_wipe(keys.sk, sizeof(keys.sk)); + return NULL; + } + } + cst_parliament_meta_t before; + cst_parliament_load(&before); + char prev_chair[64]; + snprintf(prev_chair, sizeof(prev_chair), "%s", before.chair_instance); + if (cst_parliament_claim_chair(instance_id, device_label) != 0) { + if (err && err_sz) + snprintf(err, err_sz, "claim chair failed"); + crypto_wipe(keys.seed, sizeof(keys.seed)); + crypto_wipe(keys.sk, sizeof(keys.sk)); + return NULL; + } + char *ctx = cst_parliament_context_get(); + char *dev = cst_parliament_device_context(); + cst_buf_t hand = {0}; + cst_buf_puts(&hand, "LOGIN_OK\n"); + cst_buf_printf(&hand, "chair=%s\n", instance_id); + if (prev_chair[0] && strcmp(prev_chair, instance_id) != 0) + cst_buf_printf(&hand, "handover_from=%s\n", prev_chair); + else + cst_buf_puts(&hand, "handover_from=\n"); + cst_buf_printf(&hand, "device_context=\n%s", dev ? dev : ""); + cst_buf_puts(&hand, "--- parliament_context ---\n"); + cst_buf_puts(&hand, ctx ? ctx : ""); + free(ctx); + free(dev); + crypto_wipe(keys.seed, sizeof(keys.seed)); + crypto_wipe(keys.sk, sizeof(keys.sk)); + crypto_wipe(sk, sizeof(sk)); + return cst_buf_steal(&hand); +} diff --git a/src/parliament_cli.c b/src/parliament_cli.c new file mode 100644 index 0000000..34cce18 --- /dev/null +++ b/src/parliament_cli.c @@ -0,0 +1,216 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif +#include "cst_parliament_cli.h" +#include "cst_discover.h" +#include "cst_net.h" +#include "cst_parliament.h" +#include "cst_peer.h" +#include "cst_realm.h" +#include "os_compat.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#else +#include +#endif + +static void sleep_ms(int ms) { +#ifdef _WIN32 + Sleep((DWORD)ms); +#else + struct timespec ts = {ms / 1000, (ms % 1000) * 1000000L}; + nanosleep(&ts, NULL); +#endif +} + +int cst_parliament_cli_realm_new(void) { + char dir[OS_PATH_MAX], fp[33]; + if (cst_realm_dir(dir, sizeof(dir)) != 0) { + fprintf(stderr, "cannot resolve realm directory\n"); + return 1; + } + cst_realm_keys_t k; + if (cst_realm_generate(&k) != 0 || cst_realm_save(dir, &k) != 0) { + fprintf(stderr, "cannot create realm keys in %s\n", dir); + return 1; + } + cst_realm_fingerprint(k.pk, fp); + printf("realm_created dir=%s fingerprint=%s\n", dir, fp); + return 0; +} + +int cst_parliament_cli_realm_password_init(const char *password) { + if (!password || !password[0]) { + fprintf(stderr, "password required (or CST_REALM_PASSWORD)\n"); + return 1; + } + char dir[OS_PATH_MAX], fp[33]; + if (cst_realm_dir(dir, sizeof(dir)) != 0) { + fprintf(stderr, "cannot resolve realm directory\n"); + return 1; + } + if (cst_realm_init_password(dir, password) != 0) { + fprintf(stderr, "password realm init failed\n"); + return 1; + } + cst_realm_keys_t k; + if (cst_realm_unlock_password(dir, password, &k) != 0) + return 1; + cst_realm_fingerprint(k.pk, fp); + printf("realm_password_mode dir=%s fingerprint=%s\n", dir, fp); + return 0; +} + +int cst_parliament_cli_login(const char *password, const char *instance_id, + const char *device_label) { + char err[256]; + char *hand = cst_parliament_login(password, instance_id, device_label, err, + sizeof(err)); + if (!hand) { + fprintf(stderr, "%s\n", err[0] ? err : "login failed"); + return 1; + } + fputs(hand, stdout); + free(hand); + return 0; +} + +int cst_parliament_cli_new_project(const char *project_id) { + if (cst_parliament_new_project(project_id) != 0) { + fprintf(stderr, "new project failed\n"); + return 1; + } + printf("parliament_new_project id=%s\n", + project_id && project_id[0] ? project_id : "default"); + return 0; +} + +int cst_parliament_cli_realm_enroll(const char *instance_id, const char *label) { + if (!instance_id || !instance_id[0]) { + fprintf(stderr, "instance id required\n"); + return 1; + } + char dir[OS_PATH_MAX]; + cst_realm_keys_t k; + if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &k) != 0) { + fprintf(stderr, "load realm keys failed (run --realm-new first)\n"); + return 1; + } + cst_realm_cert_t cert; + uint8_t seed[32], sk[64]; + if (cst_realm_enroll(&k, instance_id, label, 0, &cert, seed, sk) != 0 || + cst_realm_instance_save(instance_id, &cert, seed) != 0) { + fprintf(stderr, "enroll/save failed\n"); + return 1; + } + char fp[33]; + cst_realm_fingerprint(k.pk, fp); + printf("enrolled id=%s label=%s realm=%s\n", instance_id, + label ? label : "", fp); + return 0; +} + +int cst_parliament_cli_peer_listen(int port, const char *instance_id) { + if (!instance_id || !instance_id[0]) { + fprintf(stderr, "instance id required\n"); + return 1; + } + char dir[OS_PATH_MAX], err[256]; + cst_realm_keys_t realm; + cst_realm_cert_t cert; + uint8_t sk[64]; + if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 || + cst_realm_instance_load(instance_id, &cert, sk) != 0) { + fprintf(stderr, "cannot load realm/instance %s\n", instance_id); + return 1; + } + if (cst_realm_revoked(dir, instance_id)) { + fprintf(stderr, "instance revoked\n"); + return 1; + } + os_net_init(); + os_socket_t ls = os_net_listen("0.0.0.0", port > 0 ? port : 0, err, sizeof(err)); + if (ls == OS_SOCKET_BAD) { + fprintf(stderr, "listen failed: %s\n", err); + return 1; + } + int bound = os_net_port(ls); + printf("peer_listen port=%d id=%s\n", bound, instance_id); + fflush(stdout); + cst_peer_session_t sess; + if (cst_peer_accept((intptr_t)ls, &cert, sk, realm.pk, 60000, &sess, err, + sizeof(err)) != 0) { + fprintf(stderr, "accept/handshake failed: %s\n", err); + os_net_close(ls); + return 1; + } + printf("peer_ok remote=%s host=%s\n", sess.remote.instance_id, sess.host); + os_net_close(ls); + return 0; +} + +int cst_parliament_cli_peer_dial(const char *host, int port, + const char *instance_id) { + if (!host || port <= 0 || !instance_id) { + fprintf(stderr, "host, port and instance id required\n"); + return 1; + } + char dir[OS_PATH_MAX], err[256]; + cst_realm_keys_t realm; + cst_realm_cert_t cert; + uint8_t sk[64]; + if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 || + cst_realm_instance_load(instance_id, &cert, sk) != 0) { + fprintf(stderr, "cannot load realm/instance %s\n", instance_id); + return 1; + } + cst_peer_session_t sess; + if (cst_peer_dial(host, port, &cert, sk, realm.pk, 30000, &sess, err, + sizeof(err)) != 0) { + fprintf(stderr, "dial/handshake failed: %s\n", err); + return 1; + } + printf("peer_ok remote=%s\n", sess.remote.instance_id); + return 0; +} + +int cst_parliament_cli_discover(const char *instance_id, int peer_port, + int wait_ms) { + if (!instance_id || peer_port <= 0) { + fprintf(stderr, "instance id and peer port required\n"); + return 1; + } + char dir[OS_PATH_MAX], fp[33]; + cst_realm_keys_t realm; + if (cst_realm_dir(dir, sizeof(dir)) != 0 || cst_realm_load(dir, &realm) != 0 || + cst_realm_fingerprint(realm.pk, fp) != 0) { + fprintf(stderr, "cannot load realm\n"); + return 1; + } + cst_discover_t *d = cst_discover_start(fp, instance_id, instance_id, peer_port); + if (!d) { + fprintf(stderr, "discover start failed\n"); + return 1; + } + int left = wait_ms > 0 ? wait_ms : 3000; + while (left > 0) { + cst_peer_info_t peers[16]; + int n = cst_discover_peers(d, peers, 16); + for (int i = 0; i < n; i++) { + printf("peer iid=%s host=%s port=%d name=%s\n", peers[i].instance_id, + peers[i].host, peers[i].port, peers[i].name); + } + if (n > 0) + break; + sleep_ms(200); + left -= 200; + } + cst_discover_stop(d); + return 0; +} diff --git a/src/realm.c b/src/realm.c index 9e83966..724d537 100644 --- a/src/realm.c +++ b/src/realm.c @@ -213,6 +213,101 @@ int cst_realm_fingerprint(const uint8_t pk[32], char out[33]) { return 0; } +int cst_realm_derive_password(cst_realm_keys_t *out, const char *password, + const uint8_t salt[16]) { + if (!out || !password || !password[0] || !salt) + return -1; + crypto_argon2_config cfg = { + .algorithm = CRYPTO_ARGON2_ID, + .nb_blocks = 8192, /* 8 MiB */ + .nb_passes = 3, + .nb_lanes = 1, + }; + crypto_argon2_inputs in = { + .pass = (const uint8_t *)password, + .salt = salt, + .pass_size = (uint32_t)strlen(password), + .salt_size = 16, + }; + void *work = malloc((size_t)cfg.nb_blocks * 1024); + if (!work) + return -1; + uint8_t seed[32]; + crypto_argon2(seed, 32, work, cfg, in, crypto_argon2_no_extras); + free(work); + int rc = cst_realm_derive(out, seed); + crypto_wipe(seed, sizeof(seed)); + return rc; +} + +int cst_realm_is_password_mode(const char *dir) { + if (!dir) + return 0; + char path[OS_PATH_MAX]; + if (os_path_join(path, sizeof(path), dir, "password.salt") != 0) + return 0; + FILE *f = fopen(path, "rb"); + if (!f) + return 0; + fclose(f); + return 1; +} + +int cst_realm_init_password(const char *dir, const char *password) { + if (!dir || !password || !password[0]) + return -1; + if (os_mkdir_p(dir, 0700) != 0) + return -1; + uint8_t salt[16]; + if (fill_random(salt, sizeof(salt)) != 0) + return -1; + cst_realm_keys_t k; + if (cst_realm_derive_password(&k, password, salt) != 0) + return -1; + char path[OS_PATH_MAX]; + if (os_path_join(path, sizeof(path), dir, "password.salt") != 0) + return -1; + if (write_hex_file(path, salt, 16) != 0) + return -1; + if (os_path_join(path, sizeof(path), dir, "public.key") != 0) + return -1; + if (write_hex_file(path, k.pk, 32) != 0) + return -1; + /* Never persist the seed/private key in password mode. */ + if (os_path_join(path, sizeof(path), dir, "mode") != 0) + return -1; + FILE *f = fopen(path, "wb"); + if (!f) + return -1; + fputs("password\n", f); + fclose(f); + crypto_wipe(k.seed, sizeof(k.seed)); + crypto_wipe(k.sk, sizeof(k.sk)); + return 0; +} + +int cst_realm_unlock_password(const char *dir, const char *password, + cst_realm_keys_t *out) { + if (!dir || !password || !out) + return -1; + char path[OS_PATH_MAX]; + uint8_t salt[16], pk_file[32]; + if (os_path_join(path, sizeof(path), dir, "password.salt") != 0 || + read_hex_file(path, salt, 16) != 0) + return -1; + if (cst_realm_derive_password(out, password, salt) != 0) + return -1; + if (os_path_join(path, sizeof(path), dir, "public.key") != 0 || + read_hex_file(path, pk_file, 32) != 0 || + memcmp(pk_file, out->pk, 32) != 0) { + crypto_wipe(out->seed, sizeof(out->seed)); + crypto_wipe(out->sk, sizeof(out->sk)); + memset(out, 0, sizeof(*out)); + return -1; /* wrong password */ + } + return 0; +} + int cst_realm_enroll(const cst_realm_keys_t *realm, const char *instance_id, const char *label, int64_t not_after, cst_realm_cert_t *cert, uint8_t instance_seed[32], uint8_t instance_sk[64]) { @@ -367,3 +462,81 @@ int cst_realm_revoked(const char *dir, const char *instance_id) { fclose(f); return hit; } + +static int instance_dir(const char *instance_id, char *out, size_t n) { + char realm[OS_PATH_MAX], base[OS_PATH_MAX]; + if (cst_realm_dir(realm, sizeof(realm)) != 0) + return -1; + if (os_path_join(base, sizeof(base), realm, "instances") != 0) + return -1; + if (os_mkdir_p(base, 0700) != 0) + return -1; + return os_path_join(out, n, base, instance_id); +} + +int cst_realm_instance_save(const char *instance_id, const cst_realm_cert_t *cert, + const uint8_t instance_seed[32]) { + if (!instance_id || !cert || !instance_seed) + return -1; + char dir[OS_PATH_MAX], path[OS_PATH_MAX]; + if (instance_dir(instance_id, dir, sizeof(dir)) != 0 || + os_mkdir_p(dir, 0700) != 0) + return -1; + char *json = cst_realm_cert_to_json(cert); + if (!json) + return -1; + if (os_path_join(path, sizeof(path), dir, "cert.json") != 0) { + free(json); + return -1; + } + FILE *f = fopen(path, "wb"); + int ok = 0; + if (f) { + ok = fputs(json, f) >= 0 && fputc('\n', f) >= 0; + if (fclose(f) != 0) + ok = 0; + } + free(json); + if (!ok) + return -1; + (void)os_chmod_private(path); + if (os_path_join(path, sizeof(path), dir, "seed") != 0) + return -1; + return write_hex_file(path, instance_seed, 32); +} + +int cst_realm_instance_load(const char *instance_id, cst_realm_cert_t *cert, + uint8_t instance_sk[64]) { + if (!instance_id || !cert || !instance_sk) + return -1; + char dir[OS_PATH_MAX], path[OS_PATH_MAX]; + if (instance_dir(instance_id, dir, sizeof(dir)) != 0) + return -1; + if (os_path_join(path, sizeof(path), dir, "cert.json") != 0) + return -1; + FILE *f = fopen(path, "rb"); + if (!f) + return -1; + cst_buf_t b = {0}; + char chunk[4096]; + size_t n; + while ((n = fread(chunk, 1, sizeof(chunk), f)) && b.len < 256 * 1024) + cst_buf_append(&b, chunk, n); + fclose(f); + int ok = b.buf && cst_realm_cert_from_json(b.buf, cert) == 0; + cst_buf_free(&b); + if (!ok) + return -1; + uint8_t seed[32]; + if (os_path_join(path, sizeof(path), dir, "seed") != 0 || + read_hex_file(path, seed, 32) != 0) + return -1; + uint8_t seed_copy[32], pk[32]; + memcpy(seed_copy, seed, 32); + crypto_ed25519_key_pair(instance_sk, pk, seed_copy); + crypto_wipe(seed_copy, sizeof(seed_copy)); + crypto_wipe(seed, sizeof(seed)); + if (memcmp(pk, cert->instance_pk, 32) != 0) + return -1; + return 0; +} diff --git a/src/settings.c b/src/settings.c index 3710fce..5dc1074 100644 --- a/src/settings.c +++ b/src/settings.c @@ -51,6 +51,8 @@ int cst_settings_load(cst_settings_t *s) { cJSON_GetObjectItemCaseSensitive(root, "mode")); copy_str(s->approval, sizeof(s->approval), cJSON_GetObjectItemCaseSensitive(root, "approval")); + copy_str(s->collaboration, sizeof(s->collaboration), + cJSON_GetObjectItemCaseSensitive(root, "collaboration")); s->show_thinking = cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "show_thinking")); const cJSON *tr = cJSON_GetObjectItemCaseSensitive(root, "trusted_workspaces"); @@ -111,6 +113,8 @@ int cst_settings_save(const cst_settings_t *s) { cJSON_AddStringToObject(root, "mode", s->mode); if (s->approval[0]) cJSON_AddStringToObject(root, "approval", s->approval); + if (s->collaboration[0]) + cJSON_AddStringToObject(root, "collaboration", s->collaboration); if (s->show_thinking) cJSON_AddTrueToObject(root, "show_thinking"); if (s->ntrusted) { diff --git a/src/subagent.c b/src/subagent.c index 4c1cf2b..aebf3d5 100644 --- a/src/subagent.c +++ b/src/subagent.c @@ -775,6 +775,14 @@ static int task_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, (void)ud; if (!ctx->agent) return out_text(r, 1, "error: task needs a running agent"); + { + cst_settings_t s; cst_settings_load(&s); + const char *mode = getenv("CST_COLLABORATION"); + if ((!mode || !mode[0]) && s.collaboration[0]) mode = s.collaboration; + if (mode && !strcmp(mode, "parliament")) + return out_text(r, 1, "error: collaboration=parliament replaces in-process " + "workers; use Realm peer seats"); + } const cst_agent_config_t *pcfg = cst_agent_config(ctx->agent); if (pcfg->depth > 0) return out_text(r, 1, "error: a sub-agent cannot start sub-agents; do " @@ -1179,6 +1187,13 @@ static int async_worker_tool(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c if (!ctx->agent) return out_text(out, 1, "error: no agent"); const cst_agent_config_t *cfg = cst_agent_config(ctx->agent); if (cfg->depth || !cfg->runtime) return out_text(out, 1, "error: only the main agent can schedule workers"); + { + cst_settings_t s; cst_settings_load(&s); + const char *mode = getenv("CST_COLLABORATION"); + if ((!mode || !mode[0]) && s.collaboration[0]) mode = s.collaboration; + if (mode && !strcmp(mode, "parliament")) + return out_text(out, 1, "error: collaboration=parliament replaces in-process workers; use Realm peer seats"); + } if (strcmp(c->name, "worker_start")) { const char *wt = arg(c, "worktree"); int code; if (wt && cst_runtime_resource_busy(cfg->runtime, wt)) return out_text(out, 1, "error: worker is still using this worktree; wait before review/integration"); diff --git a/tests/test_parliament.c b/tests/test_parliament.c new file mode 100644 index 0000000..a1d0f8d --- /dev/null +++ b/tests/test_parliament.c @@ -0,0 +1,58 @@ +#define _POSIX_C_SOURCE 200809L +#include "cst_parliament.h" +#include "cst_realm.h" +#include "cst_test_util.h" +#include "os_compat.h" + +#include +#include +#include +#include + +int main(void) { + char dir[2048], cfg[512]; + assert(!test_tmpdir(dir, sizeof(dir), "parliament")); + assert(snprintf(cfg, sizeof(cfg), "%s/cfg", dir) < (int)sizeof(cfg)); + assert(!os_mkdir_p(cfg, 0700)); + setenv("XDG_CONFIG_HOME", cfg, 1); + + char realm[OS_PATH_MAX]; + assert(cst_realm_dir(realm, sizeof(realm)) == 0); + assert(cst_realm_init_password(realm, "correct horse battery") == 0); + assert(cst_realm_is_password_mode(realm)); + + cst_realm_keys_t bad, good; + assert(cst_realm_unlock_password(realm, "wrong", &bad) != 0); + assert(cst_realm_unlock_password(realm, "correct horse battery", &good) == 0); + + char err[128]; + char *hand = cst_parliament_login("correct horse battery", "phone-1", "pixel", + err, sizeof(err)); + assert(hand && strstr(hand, "LOGIN_OK") && strstr(hand, "chair=phone-1")); + free(hand); + + cst_parliament_meta_t m; + assert(cst_parliament_load(&m) == 0); + assert(!strcmp(m.chair_instance, "phone-1")); + + assert(cst_parliament_context_put("shared agenda v1") == 0); + hand = cst_parliament_login("correct horse battery", "laptop-2", "thinkpad", + err, sizeof(err)); + assert(hand && strstr(hand, "handover_from=phone-1")); + assert(strstr(hand, "shared agenda v1")); + free(hand); + assert(cst_parliament_load(&m) == 0); + assert(!strcmp(m.chair_instance, "laptop-2")); + + assert(cst_parliament_new_project("proj-b") == 0); + char *ctx = cst_parliament_context_get(); + assert(ctx && ctx[0] == 0); + free(ctx); + assert(cst_parliament_load(&m) == 0); + assert(!strcmp(m.project_id, "proj-b")); + assert(m.chair_instance[0] == 0); + + test_rmrf(dir); + puts("test_parliament: OK"); + return 0; +}