Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ LD_FLAGS := -s -w \
-X main.hash=$(GIT_HASH)$(DIRTY)

TARGET = out/mininaru
FMT_DIR = bot/ cli/ config/ core/ modules/ server/ util/
FMT_DIR = bot/ cli/ config/ core/ modules/ rpc/ server/ util/

COVER_OUT = out/coverage.out

Expand All @@ -19,13 +19,16 @@ GOARCH ?= $(shell go env GOARCH)
DIST_NAME = mininaru_$(GOOS)_$(GOARCH)
DIST_BIN = $(DIST_DIR)/$(DIST_NAME)/mininaru$(if $(filter windows,$(GOOS)),.exe,)

.PHONY: all build fmt vet test test-race test-cover test-all dist install uninstall clean
.PHONY: all build generate fmt vet test test-race test-cover test-all dist install uninstall clean

all: build

build:
go build -ldflags "$(LD_FLAGS)" -o $(TARGET) ./cli

generate:
sh ./scripts/generate-proto.sh

dist:
@mkdir -p $(dir $(DIST_BIN))
CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
Expand Down
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,11 @@ Builds reporting `dev` never show it. To turn the check off entirely, set

## First run

`setup` walks through the whole thing -- provider, agent, the thinking and tool
defaults, and finally the systemd user daemon -- asking one question at a time:
`setup` first asks whether this installation is a server or a paired client.
Client setup asks for the remote gRPC address, verifies its fingerprint, and
waits for pairing approval. Server setup walks through provider, agent, thinking
and tool defaults, and finally the systemd user daemon -- asking one question at
a time:

```sh
./out/mininaru setup
Expand Down Expand Up @@ -662,6 +665,76 @@ mininaru serve --api-key '<KEY>' # 127.0.0.1:8080
mininaru serve --host 0.0.0.0 --port 3000 --api-key '<KEY>'
```

The same command starts the native session-aware gRPC server on
`127.0.0.1:9090`. Its listener is independent from the HTTP API:

```sh
mininaru serve --api-key '<KEY>' --grpc-host 0.0.0.0 --grpc-port 9090
mininaru serve --grpc-only --grpc-host 0.0.0.0
```

The HTTP API remains stateless and API-key authenticated. The native gRPC API
owns sessions on the server and accepts only paired client devices over mutual
TLS; HTTP credentials and gRPC identities are not interchangeable.

### Pairing a gRPC client

The server creates a local Ed25519 certificate authority and server identity on
first start. Its public-key fingerprint is printed when the gRPC listener
starts. On the client machine, compare that value while pairing:

```sh
mininaru pair naru.example.com:9090 --name laptop
```

The client shows the server fingerprint before trusting it, creates its own
Ed25519 key locally, and prints a six-digit code plus its client fingerprint.
The server operator approves that request on the server host:

```sh
mininaru client pending
mininaru client approve 482193
```

Codes expire after five minutes and pairing attempts are rate limited. The
client private key never leaves the client; approval returns a client
certificate signed by the server's local CA. Pair non-interactively only when
the expected fingerprint came through another trusted channel:

```sh
mininaru pair naru.example.com:9090 \
--name ci-runner \
--fingerprint 'SHA256:...'
```

Successful pairing writes the server address to `client.json`, so ordinary
commands use it automatically. `--server` overrides that default:

```sh
mininaru
mininaru -p 'summarise the current session' --session
mininaru session list
mininaru session usage <id>
mininaru --server naru.example.com:9090
```

The TUI streams answer and reasoning deltas, tool progress, cancellation, and
dangerous-tool approval over one bidirectional RPC. Sessions, history, tool
logs, compaction, and token usage stay in the server's SQLite database.

Manage paired devices on the server host:

```sh
mininaru client list
mininaru client deny 482193
mininaru client revoke 'SHA256:...'
```

Revocation takes effect on the next RPC, including over an already open HTTP/2
connection. Server CA keys and server keys live under `.mininaru/pki`; client
keys and certificates live under `.mininaru/identity`. Private material and the
trust store are written with mode `0600`.

An API key is required. Pass `--api-key` or set `MININARU_API_KEY`; the server
refuses to start without one and answers `401` unless the request carries
`Authorization: Bearer <KEY>`.
Expand Down Expand Up @@ -924,6 +997,7 @@ rule: only ones classified safe are exposed, and a server configured with
## Development

```sh
make generate # regenerate protobuf and gRPC Go sources
make fmt # gofmt check, fails on unformatted files
make vet # go vet
make test # fmt + vet + unit tests
Expand Down
235 changes: 235 additions & 0 deletions api/mininaru/v1/mininaru.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
syntax = "proto3";

package mininaru.v1;

option go_package = "github.com/devproje/mininaru/rpc/gen/mininaru/v1;mininaruv1";

service PairingService {
rpc Begin(BeginPairingRequest) returns (BeginPairingResponse);
rpc Watch(WatchPairingRequest) returns (stream PairingEvent);
}

service MininaruService {
rpc ListAgents(ListAgentsRequest) returns (ListAgentsResponse);
rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse);
rpc CreateSession(CreateSessionRequest) returns (Session);
rpc GetSession(GetSessionRequest) returns (SessionDetail);
rpc RenameSession(RenameSessionRequest) returns (Session);
rpc DeleteSession(DeleteSessionRequest) returns (Empty);
rpc GetUsage(GetUsageRequest) returns (Usage);
rpc CompactSession(CompactSessionRequest) returns (CompactSessionResponse);
rpc Chat(stream ChatClientEvent) returns (stream ChatServerEvent);
}

message Empty {}

message BeginPairingRequest {
bytes public_key = 1;
string device_name = 2;
}

message BeginPairingResponse {
string request_id = 1;
string pairing_code = 2;
string client_fingerprint = 3;
int64 expires_at_unix = 4;
}

message WatchPairingRequest {
string request_id = 1;
}

message PairingEvent {
PairingState state = 1;
bytes client_certificate_pem = 2;
bytes ca_certificate_pem = 3;
string error = 4;
}

enum PairingState {
PAIRING_STATE_UNSPECIFIED = 0;
PAIRING_STATE_WAITING = 1;
PAIRING_STATE_APPROVED = 2;
PAIRING_STATE_DENIED = 3;
PAIRING_STATE_EXPIRED = 4;
}

message Agent {
string id = 1;
string name = 2;
string model = 3;
string provider = 4;
}

message Session {
string id = 1;
string agent_id = 2;
string name = 3;
}

message Message {
string id = 1;
string session_id = 2;
string role = 3;
string content = 4;
string reasoning = 5;
string status = 6;
string error = 7;
}

message ToolCall {
string id = 1;
string call_id = 2;
string message_id = 3;
string name = 4;
string arguments = 5;
string result = 6;
string status = 7;
string error = 8;
}

message UsageLine {
string kind = 1;
int64 prompt_tokens = 2;
int64 completion_tokens = 3;
int64 total_tokens = 4;
int64 cached_tokens = 5;
int64 cache_write_tokens = 6;
}

message Usage {
string session_id = 1;
repeated UsageLine lines = 2;
int64 prompt_tokens = 3;
int64 completion_tokens = 4;
int64 total_tokens = 5;
int64 cached_tokens = 6;
int64 cache_write_tokens = 7;
}

message ListAgentsRequest {}

message ListAgentsResponse {
repeated Agent agents = 1;
string default_agent_id = 2;
}

message ListSessionsRequest {
string agent = 1;
}

message ListSessionsResponse {
repeated Session sessions = 1;
}

message CreateSessionRequest {
string agent = 1;
string name = 2;
}

message GetSessionRequest {
string session_id = 1;
}

message SessionDetail {
Session session = 1;
Agent agent = 2;
repeated Message messages = 3;
int64 context_tokens = 4;
int64 context_window = 5;
bool context_known = 6;
repeated ToolCall tool_calls = 7;
}

message RenameSessionRequest {
string session_id = 1;
string name = 2;
}

message DeleteSessionRequest {
string session_id = 1;
}

message GetUsageRequest {
string session_id = 1;
}

message CompactSessionRequest {
string session_id = 1;
}

message CompactSessionResponse {
bool compacted = 1;
}

message ChatStart {
string session_id = 1;
string content = 2;
string thinking = 3;
}

message ApprovalDecision {
string request_id = 1;
ApprovalChoice choice = 2;
}

enum ApprovalChoice {
APPROVAL_CHOICE_UNSPECIFIED = 0;
APPROVAL_CHOICE_DENY = 1;
APPROVAL_CHOICE_ONCE = 2;
APPROVAL_CHOICE_SESSION = 3;
}

message ChatClientEvent {
oneof event {
ChatStart start = 1;
ApprovalDecision approval = 2;
Empty cancel = 3;
}
}

message ChatStarted {
string turn_id = 1;
}

message TextDelta {
string text = 1;
}

message ToolEvent {
string phase = 1;
string call_id = 2;
string name = 3;
string arguments = 4;
string result = 5;
string status = 6;
string error = 7;
}

message ApprovalRequest {
string request_id = 1;
string tool_name = 2;
string arguments = 3;
}

message ChatCompleted {
Message message = 1;
Usage usage = 2;
}

message ChatFailed {
string code = 1;
string message = 2;
}

message ChatServerEvent {
oneof event {
ChatStarted started = 1;
TextDelta content = 2;
TextDelta reasoning = 3;
ToolEvent tool = 4;
ApprovalRequest approval = 5;
ChatCompleted completed = 6;
ChatFailed failed = 7;
}
}
Loading
Loading