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 <cursoragent@cursor.com>
This commit is contained in:
WHC
2026-10-28 23:06:51 +08:00
co-authored by Cursor
parent 08ca613a0e
commit de9ee28773
18 changed files with 1101 additions and 8 deletions
+17
View File
@@ -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
+3 -1
View File
@@ -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)
+2 -1
View File
@@ -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 \
+2
View File
@@ -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;
+51
View File
@@ -0,0 +1,51 @@
#ifndef CST_PARLIAMENT_H
#define CST_PARLIAMENT_H
#include "cst_realm.h"
#include <stddef.h>
#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
+26
View File
@@ -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
+15
View File
@@ -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 <realm>/instances/<id>/. */
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
+1 -1
View File
@@ -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)
+14
View File
@@ -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"]
+59
View File
@@ -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"
+13 -5
View File
@@ -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) {
+135
View File
@@ -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();
+297
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#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);
}
+216
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#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;
}
+173
View File
@@ -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;
}
+4
View File
@@ -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) {
+15
View File
@@ -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");
+58
View File
@@ -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 <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
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;
}