From d4770b265f24f48ce248cdd8d0287cab3b5e9771 Mon Sep 17 00:00:00 2001 From: devproje Date: Wed, 19 Aug 2026 02:59:41 +0900 Subject: [PATCH] feat: add paired gRPC client-server mode --- Makefile | 7 +- README.md | 78 +- api/mininaru/v1/mininaru.proto | 235 ++ cli/client.go | 167 ++ cli/main.go | 39 +- cli/pair.go | 109 + cli/preference.go | 15 + cli/remote.go | 510 ++++ cli/serve.go | 66 +- cli/setup.go | 58 +- cli/setup_test.go | 31 + cli/tui/backend.go | 41 + cli/tui/client.go | 32 +- config/client.go | 5 + core/chat.go | 8 +- core/instance.go | 11 +- docs/ARCHITECTURE.md | 66 +- go.mod | 7 +- go.sum | 32 +- rpc/auth.go | 85 + rpc/client.go | 388 +++ rpc/gen/mininaru/v1/mininaru.pb.go | 2853 +++++++++++++++++++++++ rpc/gen/mininaru/v1/mininaru_grpc.pb.go | 658 ++++++ rpc/gen/mininaru/v1/style_types.go | 16 + rpc/pairing.go | 422 ++++ rpc/pairing_service.go | 164 ++ rpc/pairing_test.go | 674 ++++++ rpc/pki.go | 368 +++ rpc/server.go | 139 ++ rpc/service.go | 538 +++++ scripts/generate-proto.sh | 30 + scripts/protostyle/main.go | 331 +++ util/database.go | 3 - util/migrations/0014_rpc_clients.sql | 24 + 34 files changed, 8138 insertions(+), 72 deletions(-) create mode 100644 api/mininaru/v1/mininaru.proto create mode 100644 cli/client.go create mode 100644 cli/pair.go create mode 100644 cli/remote.go create mode 100644 cli/tui/backend.go create mode 100644 rpc/auth.go create mode 100644 rpc/client.go create mode 100644 rpc/gen/mininaru/v1/mininaru.pb.go create mode 100644 rpc/gen/mininaru/v1/mininaru_grpc.pb.go create mode 100644 rpc/gen/mininaru/v1/style_types.go create mode 100644 rpc/pairing.go create mode 100644 rpc/pairing_service.go create mode 100644 rpc/pairing_test.go create mode 100644 rpc/pki.go create mode 100644 rpc/server.go create mode 100644 rpc/service.go create mode 100644 scripts/generate-proto.sh create mode 100644 scripts/protostyle/main.go create mode 100644 util/migrations/0014_rpc_clients.sql diff --git a/Makefile b/Makefile index 22122af..2529ba1 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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) \ diff --git a/README.md b/README.md index 8545dee..e452aa8 100644 --- a/README.md +++ b/README.md @@ -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 @@ -662,6 +665,76 @@ mininaru serve --api-key '' # 127.0.0.1:8080 mininaru serve --host 0.0.0.0 --port 3000 --api-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 '' --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 +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 `. @@ -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 diff --git a/api/mininaru/v1/mininaru.proto b/api/mininaru/v1/mininaru.proto new file mode 100644 index 0000000..338fbbe --- /dev/null +++ b/api/mininaru/v1/mininaru.proto @@ -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; + } +} diff --git a/cli/client.go b/cli/client.go new file mode 100644 index 0000000..cf0c3d2 --- /dev/null +++ b/cli/client.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "time" + + mininarurpc "github.com/devproje/mininaru/rpc" + "github.com/spf13/cobra" +) + +var clientConfig *cobra.Command = &cobra.Command{ + Use: "client", + Short: "manage gRPC client devices and pairing requests", + Args: usageArgs(cobra.NoArgs), +} + +var clientList *cobra.Command = &cobra.Command{ + Use: "list", + Short: "list paired gRPC client devices", + Args: usageArgs(cobra.NoArgs), + RunE: clientListExecute, +} + +var clientPending *cobra.Command = &cobra.Command{ + Use: "pending", + Short: "list waiting gRPC pairing requests", + Args: usageArgs(cobra.NoArgs), + RunE: clientPendingExecute, +} + +var clientApprove *cobra.Command = &cobra.Command{ + Use: "approve ", + Short: "approve a waiting gRPC pairing request", + Args: usageArgs(cobra.ExactArgs(1)), + RunE: clientApproveExecute, +} + +var clientDeny *cobra.Command = &cobra.Command{ + Use: "deny ", + Short: "deny a waiting gRPC pairing request", + Args: usageArgs(cobra.ExactArgs(1)), + RunE: clientDenyExecute, +} + +var clientRevoke *cobra.Command = &cobra.Command{ + Use: "revoke ", + Short: "revoke a paired gRPC client device", + Args: usageArgs(cobra.ExactArgs(1)), + RunE: clientRevokeExecute, +} + +func clientState(device *mininarurpc.ClientDevice) string { + if device.RevokedAt != 0 { + return "revoked" + } + + return "active" +} + +func clientSeen(value int64) string { + if value == 0 { + return "never" + } + + return time.Unix(value, 0).Format("2006-01-02 15:04") +} + +func clientListExecute(cmd *cobra.Command, args []string) error { + var devices []*mininarurpc.ClientDevice + var device *mininarurpc.ClientDevice + var rows *uiRows + + var err error + + devices, err = mininarurpc.ClientList() + if err != nil { + return err + } + if len(devices) == 0 { + uiEmpty("no grpc clients paired") + return nil + } + + rows = uiTable("NAME", "FINGERPRINT", "STATE", "LAST SEEN") + for _, device = range devices { + rows.row(device.Name, device.Fingerprint, clientState(device), clientSeen(device.LastSeenAt)) + } + rows.flush() + + return nil +} + +func clientPendingExecute(cmd *cobra.Command, args []string) error { + var requests []*mininarurpc.PairingRequest + var request *mininarurpc.PairingRequest + var rows *uiRows + + var err error + + requests, err = mininarurpc.PairingPending() + if err != nil { + return err + } + if len(requests) == 0 { + uiEmpty("no grpc pairing requests waiting") + return nil + } + + rows = uiTable("CODE", "DEVICE", "FINGERPRINT", "EXPIRES") + for _, request = range requests { + rows.row(request.Code, request.Name, request.Fingerprint, clientSeen(request.ExpiresAt)) + } + rows.flush() + + return nil +} + +func clientApproveExecute(cmd *cobra.Command, args []string) error { + var device *mininarurpc.ClientDevice + + var err error + + device, err = mininarurpc.PairingApprove(args[0]) + if err != nil { + return err + } + + uiOk("paired %s (%s)", device.Name, device.Fingerprint) + + return nil +} + +func clientDenyExecute(cmd *cobra.Command, args []string) error { + var err error + + err = mininarurpc.PairingDeny(args[0]) + if err != nil { + return err + } + + uiOk("denied pairing request %s", args[0]) + + return nil +} + +func clientRevokeExecute(cmd *cobra.Command, args []string) error { + var err error + + err = mininarurpc.ClientRevoke(args[0]) + if err != nil { + return err + } + + uiOk("revoked grpc client %s", args[0]) + + return nil +} + +func init() { + clientConfig.AddCommand(clientList) + clientConfig.AddCommand(clientPending) + clientConfig.AddCommand(clientApprove) + clientConfig.AddCommand(clientDeny) + clientConfig.AddCommand(clientRevoke) +} diff --git a/cli/main.go b/cli/main.go index 7f330be..b9cb08b 100644 --- a/cli/main.go +++ b/cli/main.go @@ -39,6 +39,7 @@ var ( resumeRef string chatAgentRef string promptRef string + serverRef string logLevelRef string logFormatRef string @@ -51,7 +52,8 @@ var root *cobra.Command = &cobra.Command{ Running it with no arguments opens the chat client with the global agent, either resuming the session you name or starting a fresh one. The subcommands configure -providers, agents, tools and bots, and serve the OpenAI compatible API.`, +providers, agents, tools and bots, and serve the paired gRPC and OpenAI +compatible APIs.`, Example: ` mininaru mininaru --resume mininaru -a reviewer -p "summarise the diff on stdin" - @@ -252,6 +254,24 @@ func execute(cmd *cobra.Command, args []string) error { return nil } + if promptRef != "" { + content, err = promptContent(promptRef, os.Stdin) + if err != nil { + return err + } + + if content == "" { + return fmt.Errorf("prompt is empty") + } + } + + if serverRef == "" { + serverRef = config.Client.Server.Address + } + if serverRef != "" { + return executeRemote(cmd.Context(), args, content) + } + if config.Client.Tools.Enabled { err = withProgress(cmd.Context(), "connecting to mcp servers", func() error { return modules.MCPInit(cmd.Context()) @@ -266,17 +286,6 @@ func execute(cmd *cobra.Command, args []string) error { return err } - if promptRef != "" { - content, err = promptContent(promptRef, os.Stdin) - if err != nil { - return err - } - - if content == "" { - return fmt.Errorf("prompt is empty") - } - } - session, err = resolveSession(agent, args) if err != nil { return err @@ -301,6 +310,7 @@ func rootInit() { "diagnostic log level: "+strings.Join(util.LogLevels(), ", ")+" (default info, or "+util.LogLevelEnv+")") root.PersistentFlags().StringVar(&logFormatRef, "log-format", "", "diagnostic log format: "+strings.Join(util.LogFormats(), ", ")+" (default auto, or "+util.LogFormatEnv+")") + root.PersistentFlags().StringVar(&serverRef, "server", "", "paired gRPC server address, defaults to client.json") root.PersistentFlags().BoolVar(&util.AppDebug, "debug", false, "enable debugging mode") root.PersistentFlags().BoolVar(&config.AllowDangerousTools, "allow-dangerous-tools", false, "allow file writes and shell commands for this run") @@ -335,8 +345,9 @@ func rootInit() { skillConfig.GroupID = groupConfig webConfig.GroupID = groupConfig botConfig.GroupID = groupConfig - serve.GroupID = groupService + clientConfig.GroupID = groupService + pairCmd.GroupID = groupService daemonConfig.GroupID = groupService updateCmd.GroupID = groupService @@ -352,6 +363,8 @@ func rootInit() { root.AddCommand(skillConfig) root.AddCommand(webConfig) root.AddCommand(botConfig) + root.AddCommand(clientConfig) + root.AddCommand(pairCmd) root.AddCommand(daemonConfig) root.AddCommand(updateCmd) } diff --git a/cli/pair.go b/cli/pair.go new file mode 100644 index 0000000..0c4796d --- /dev/null +++ b/cli/pair.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/devproje/mininaru/config" + mininarurpc "github.com/devproje/mininaru/rpc" + "github.com/spf13/cobra" +) + +var ( + pairNameRef string + pairFingerprintRef string +) + +var pairCmd *cobra.Command = &cobra.Command{ + Use: "pair
", + Short: "pair this device with a mininaru grpc server", + Long: `Verify the server fingerprint, create an Ed25519 device identity, and +wait for the server operator to approve the one-time pairing code.`, + Example: ` mininaru pair naru.example.com:9090 + mininaru pair 127.0.0.1:9090 --name laptop`, + Args: usageArgs(cobra.ExactArgs(1)), + RunE: pairExecute, +} + +func pairDeviceName() string { + var name string + + if pairNameRef != "" { + return pairNameRef + } + + name, _ = os.Hostname() + if name == "" { + name = "mininaru-client" + } + + return name +} + +func pairTrust(fingerprint, expected string) (bool, error) { + if expected != "" { + if strings.TrimSpace(expected) != fingerprint { + return false, fmt.Errorf("server fingerprint mismatch: got %s", fingerprint) + } + + return true, nil + } + + fmt.Fprintf(askOut, "Server fingerprint:\n%s\n\n", fingerprint) + + return askConfirm("Trust this server", false) +} + +func pairWithServer(ctx context.Context, address, name, expected string) error { + var fingerprint string + var trusted bool + + var err error + + address = strings.TrimSpace(address) + fingerprint, err = mininarurpc.ServerFingerprint(ctx, address) + if err != nil { + return err + } + + trusted, err = pairTrust(fingerprint, expected) + if err != nil { + return err + } + if !trusted { + return fmt.Errorf("server was not trusted") + } + + err = mininarurpc.Pair(ctx, address, name, fingerprint, func(request *mininarurpc.PairingRequest) { + uiNote("pairing code: %s", request.Code) + uiNote("client fingerprint: %s", request.Fingerprint) + uiNote("waiting for approval on the server") + }) + if err != nil { + return err + } + + config.Client.Server.Address = address + err = config.ClientSave() + if err != nil { + return err + } + + uiOk("paired with %s", address) + + return nil +} + +func pairExecute(cmd *cobra.Command, args []string) error { + return pairWithServer(cmd.Context(), args[0], pairDeviceName(), pairFingerprintRef) +} + +func init() { + pairCmd.Flags().StringVar(&pairNameRef, "name", "", "device name shown to the server operator") + pairCmd.Flags().StringVar(&pairFingerprintRef, "fingerprint", "", "expected server fingerprint for non-interactive pairing") +} diff --git a/cli/preference.go b/cli/preference.go index 04b065c..80ecd7b 100644 --- a/cli/preference.go +++ b/cli/preference.go @@ -755,6 +755,10 @@ func sessionListExecute(cmd *cobra.Command, args []string) error { var err error + if activeServerAddress() != "" { + return remoteSessionListExecute(cmd.Context()) + } + target, err = sessionAgent() if err != nil { return err @@ -821,6 +825,10 @@ func sessionUsageExecute(cmd *cobra.Command, args []string) error { var err error + if activeServerAddress() != "" { + return remoteSessionUsageExecute(cmd.Context(), args) + } + session, err = sessionUsageTarget(args) if err != nil { return err @@ -857,6 +865,10 @@ func sessionRemoveExecute(cmd *cobra.Command, args []string) error { var err error + if activeServerAddress() != "" { + return remoteSessionRemoveExecute(cmd.Context(), args[0]) + } + target, err = sessionAgent() if err != nil { return err @@ -887,6 +899,9 @@ func sessionRenameExecute(cmd *cobra.Command, args []string) error { if sessionNameRef == "" { return usageErrorf("session name is required, pass --name") } + if activeServerAddress() != "" { + return remoteSessionRenameExecute(cmd.Context(), args[0], sessionNameRef) + } return core.SessionUpdate(args[0], sessionNameRef) } diff --git a/cli/remote.go b/cli/remote.go new file mode 100644 index 0000000..326d8ac --- /dev/null +++ b/cli/remote.go @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/devproje/mininaru/cli/tui" + "github.com/devproje/mininaru/config" + "github.com/devproje/mininaru/core" + "github.com/devproje/mininaru/modules" + mininarurpc "github.com/devproje/mininaru/rpc" + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "google.golang.org/grpc" +) + +type remoteBackend struct { + client mininaruv1.MininaruServiceClient + toolCalls map[string][]*core.ToolCall +} + +func activeServerAddress() string { + if serverRef != "" { + return serverRef + } + + return config.Client.Server.Address +} + +func remoteConnect(ctx context.Context) (*grpc.ClientConn, mininaruv1.MininaruServiceClient, error) { + var connection *grpc.ClientConn + + var err error + + connection, err = mininarurpc.Dial(ctx, activeServerAddress()) + if err != nil { + return nil, nil, err + } + + return connection, mininaruv1.NewMininaruServiceClient(connection), nil +} + +func coreMessage(message *mininaruv1.Message) *core.Message { + if message == nil { + return nil + } + + return &core.Message{Id: message.GetId(), SessionId: message.GetSessionId(), Role: message.GetRole(), + Content: message.GetContent(), Reasoning: message.GetReasoning(), Status: message.GetStatus(), Error: message.GetError()} +} + +func coreSession(session *mininaruv1.Session) *core.Session { + if session == nil { + return nil + } + + return &core.Session{Id: session.GetId(), AgentId: session.GetAgentId(), Name: session.GetName()} +} + +func coreAgent(agent *mininaruv1.Agent) *core.NaruAgent { + if agent == nil { + return nil + } + + return &core.NaruAgent{Id: agent.GetId(), Name: agent.GetName(), Model: agent.GetModel()} +} + +func coreUsage(usage *mininaruv1.Usage) *core.UsageTotals { + var totals core.UsageTotals + var line *mininaruv1.UsageLine + + if usage == nil { + return &totals + } + + totals = core.UsageTotals{SessionId: usage.GetSessionId(), PromptTokens: usage.GetPromptTokens(), + CompletionTokens: usage.GetCompletionTokens(), TotalTokens: usage.GetTotalTokens(), + CachedTokens: usage.GetCachedTokens(), CacheWriteTokens: usage.GetCacheWriteTokens()} + for _, line = range usage.GetLines() { + totals.Lines = append(totals.Lines, core.UsageLine{Kind: line.GetKind(), PromptTokens: line.GetPromptTokens(), + CompletionTokens: line.GetCompletionTokens(), TotalTokens: line.GetTotalTokens(), + CachedTokens: line.GetCachedTokens(), CacheWriteTokens: line.GetCacheWriteTokens()}) + } + + return &totals +} + +func coreToolCall(call *mininaruv1.ToolCall) *core.ToolCall { + if call == nil { + return nil + } + + return &core.ToolCall{Id: call.GetId(), CallId: call.GetCallId(), MessageId: call.GetMessageId(), Name: call.GetName(), + Arguments: call.GetArguments(), Result: call.GetResult(), Status: call.GetStatus(), Error: call.GetError()} +} + +func remoteTool(event *mininaruv1.ToolEvent) core.ToolEvent { + if event == nil { + return core.ToolEvent{} + } + + return core.ToolEvent{Phase: event.GetPhase(), CallId: event.GetCallId(), Name: event.GetName(), + Arguments: event.GetArguments(), Result: event.GetResult(), Status: event.GetStatus(), Error: event.GetError()} +} + +func remoteApproval(ctx context.Context, stream mininaruv1.MininaruService_ChatClient, + request *mininaruv1.ApprovalRequest, approve core.ToolApprovalFunc) error { + var allowed bool + var choice mininaruv1.ApprovalChoice + + var err error + + if approve != nil { + allowed, err = approve(ctx, modules.Def{Name: request.GetToolName()}, request.GetArguments()) + if err != nil { + return err + } + } + + choice = mininaruv1.ApprovalChoice_APPROVAL_CHOICE_DENY + if allowed { + choice = mininaruv1.ApprovalChoice_APPROVAL_CHOICE_ONCE + } + + return stream.Send(&mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Approval{Approval: &mininaruv1.ApprovalDecision{ + RequestId: request.GetRequestId(), Choice: choice}}}) +} + +func (r *remoteBackend) Chat(ctx context.Context, session *core.Session, agent *core.NaruAgent, content string, + onContent, onReasoning func(string), onTool core.ToolEventFunc, approve core.ToolApprovalFunc) (*core.Message, error) { + var stream mininaruv1.MininaruService_ChatClient + var event *mininaruv1.ChatServerEvent + var failed *mininaruv1.ChatFailed + + var err error + + stream, err = r.client.Chat(ctx) + if err != nil { + return nil, err + } + + err = stream.Send(&mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Start{Start: &mininaruv1.ChatStart{ + SessionId: session.Id, Content: content, Thinking: config.Client.Thinking.Level}}}) + if err != nil { + return nil, err + } + + for { + event, err = stream.Recv() + if err != nil { + if err == io.EOF { + return nil, fmt.Errorf("grpc chat ended without a result") + } + return nil, err + } + if event.GetContent() != nil && onContent != nil { + onContent(event.GetContent().GetText()) + } + if event.GetReasoning() != nil && onReasoning != nil { + onReasoning(event.GetReasoning().GetText()) + } + if event.GetTool() != nil && onTool != nil { + onTool(remoteTool(event.GetTool())) + } + if event.GetApproval() != nil { + err = remoteApproval(ctx, stream, event.GetApproval(), approve) + if err != nil { + return nil, err + } + } + if event.GetCompleted() != nil { + return coreMessage(event.GetCompleted().GetMessage()), nil + } + failed = event.GetFailed() + if failed != nil { + return nil, fmt.Errorf("%s: %s", failed.GetCode(), failed.GetMessage()) + } + } +} + +func (r *remoteBackend) Compact(ctx context.Context, agent *core.NaruAgent, session *core.Session) (bool, error) { + var response *mininaruv1.CompactSessionResponse + + var err error + + response, err = r.client.CompactSession(ctx, &mininaruv1.CompactSessionRequest{SessionId: session.Id}) + if err != nil { + return false, err + } + + return response.GetCompacted(), nil +} + +func (r *remoteBackend) Usage(sessionId string) (*core.UsageTotals, error) { + var usage *mininaruv1.Usage + + var err error + + usage, err = r.client.GetUsage(context.Background(), &mininaruv1.GetUsageRequest{SessionId: sessionId}) + if err != nil { + return nil, err + } + + return coreUsage(usage), nil +} + +func (r *remoteBackend) Context(sessionId string) (int64, int64, bool, error) { + var detail *mininaruv1.SessionDetail + + var err error + + detail, err = r.client.GetSession(context.Background(), &mininaruv1.GetSessionRequest{SessionId: sessionId}) + if err != nil { + return 0, 0, false, err + } + + return detail.GetContextTokens(), detail.GetContextWindow(), detail.GetContextKnown(), nil +} + +func (r *remoteBackend) ToolCalls(messageId string) ([]*core.ToolCall, error) { + return append([]*core.ToolCall(nil), r.toolCalls[messageId]...), nil +} + +func remoteAgent(response *mininaruv1.ListAgentsResponse) (*mininaruv1.Agent, error) { + var agent *mininaruv1.Agent + var desired string + + desired = chatAgentRef + if desired == "" { + desired = response.GetDefaultAgentId() + } + + for _, agent = range response.GetAgents() { + if agent.GetId() == desired || agent.GetName() == desired { + return agent, nil + } + } + + if desired == "" && len(response.GetAgents()) > 0 { + return response.GetAgents()[0], nil + } + + return nil, fmt.Errorf("agent %s not found on server", desired) +} + +func remoteSession(ctx context.Context, client mininaruv1.MininaruServiceClient, + agent *mininaruv1.Agent, args []string) (*mininaruv1.SessionDetail, error) { + var id string + var sessions *mininaruv1.ListSessionsResponse + var created *mininaruv1.Session + var detail *mininaruv1.SessionDetail + + var err error + + id = sessionIdRef + if id == "" { + id = resumeRef + } + if id == latestSession && len(args) == 1 { + id = args[0] + } + + if id == latestSession { + sessions, err = client.ListSessions(ctx, &mininaruv1.ListSessionsRequest{Agent: agent.GetName()}) + if err != nil { + return nil, err + } + if len(sessions.GetSessions()) > 0 { + id = sessions.GetSessions()[len(sessions.GetSessions())-1].GetId() + } else { + id = "" + } + } + + if id == "" { + created, err = client.CreateSession(ctx, &mininaruv1.CreateSessionRequest{Agent: agent.GetName(), + Name: time.Now().Format("2006-01-02 15:04")}) + if err != nil { + return nil, err + } + id = created.GetId() + } + + detail, err = client.GetSession(ctx, &mininaruv1.GetSessionRequest{SessionId: id}) + if err != nil { + return nil, err + } + if detail.GetAgent().GetId() != agent.GetId() { + return nil, fmt.Errorf("session %s belongs to agent %s, not %s", id, detail.GetAgent().GetName(), agent.GetName()) + } + + return detail, nil +} + +func runRemotePrompt(ctx context.Context, backend *remoteBackend, session *core.Session, agent *core.NaruAgent, content string) error { + var message *core.Message + var waiting *progress + + var err error + + waiting = progressStart(ctx, "thinking") + message, err = backend.Chat(ctx, session, agent, content, nil, func(delta string) { + waiting.stop() + if config.Client.Thinking.Show { + fmt.Fprint(os.Stderr, delta) + } + }, func(event core.ToolEvent) { + waiting.stop() + promptToolLog(os.Stderr, event) + }, nil) + waiting.stop() + if err != nil { + return err + } + + fmt.Fprintln(os.Stdout, message.Content) + + return nil +} + +func executeRemote(cmdCtx context.Context, args []string, content string) error { + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + var listed *mininaruv1.ListAgentsResponse + var selected *mininaruv1.Agent + var detail *mininaruv1.SessionDetail + var session *core.Session + var agent *core.NaruAgent + var history []*core.Message + var message *mininaruv1.Message + var call *mininaruv1.ToolCall + var backend remoteBackend + + var err error + + connection, err = mininarurpc.Dial(cmdCtx, serverRef) + if err != nil { + return err + } + defer connection.Close() + + client = mininaruv1.NewMininaruServiceClient(connection) + listed, err = client.ListAgents(cmdCtx, &mininaruv1.ListAgentsRequest{}) + if err != nil { + return err + } + selected, err = remoteAgent(listed) + if err != nil { + return err + } + detail, err = remoteSession(cmdCtx, client, selected, args) + if err != nil { + return err + } + + session = coreSession(detail.GetSession()) + agent = coreAgent(detail.GetAgent()) + for _, message = range detail.GetMessages() { + history = append(history, coreMessage(message)) + } + backend.client = client + backend.toolCalls = make(map[string][]*core.ToolCall) + for _, call = range detail.GetToolCalls() { + backend.toolCalls[call.GetMessageId()] = append(backend.toolCalls[call.GetMessageId()], coreToolCall(call)) + } + + if content != "" { + return runRemotePrompt(cmdCtx, &backend, session, agent, content) + } + + return tui.RunWithBackend(session, agent, history, updateNotice(), &backend) +} + +func remoteSessionListExecute(ctx context.Context) error { + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + var sessions *mininaruv1.ListSessionsResponse + var session *mininaruv1.Session + var usage *mininaruv1.Usage + var rows *uiRows + + var err error + + connection, client, err = remoteConnect(ctx) + if err != nil { + return err + } + defer connection.Close() + + sessions, err = client.ListSessions(ctx, &mininaruv1.ListSessionsRequest{Agent: sessionAgentIdRef}) + if err != nil { + return err + } + if len(sessions.GetSessions()) == 0 { + uiEmpty("no sessions on the grpc server yet") + return nil + } + + rows = uiTable("ID", "TOKENS", "NAME") + for _, session = range sessions.GetSessions() { + usage, err = client.GetUsage(ctx, &mininaruv1.GetUsageRequest{SessionId: session.GetId()}) + if err != nil { + return err + } + rows.row(session.GetId(), tokenCount(usage.GetTotalTokens()), session.GetName()) + } + rows.flush() + + return nil +} + +func remoteUsageSession(ctx context.Context, client mininaruv1.MininaruServiceClient, args []string) (string, error) { + var sessions *mininaruv1.ListSessionsResponse + + var err error + + if len(args) == 1 { + return args[0], nil + } + + sessions, err = client.ListSessions(ctx, &mininaruv1.ListSessionsRequest{Agent: sessionAgentIdRef}) + if err != nil { + return "", err + } + if len(sessions.GetSessions()) == 0 { + return "", configErrorf("no sessions on the grpc server yet") + } + + return sessions.GetSessions()[len(sessions.GetSessions())-1].GetId(), nil +} + +func remoteSessionUsageExecute(ctx context.Context, args []string) error { + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + var sessionId string + var usage *mininaruv1.Usage + var line *mininaruv1.UsageLine + var rows *uiRows + + var err error + + connection, client, err = remoteConnect(ctx) + if err != nil { + return err + } + defer connection.Close() + + sessionId, err = remoteUsageSession(ctx, client, args) + if err != nil { + return err + } + usage, err = client.GetUsage(ctx, &mininaruv1.GetUsageRequest{SessionId: sessionId}) + if err != nil { + return err + } + if usage.GetTotalTokens() == 0 { + uiEmpty("no token usage recorded for %s yet", sessionId) + return nil + } + + rows = uiTable("KIND", "PROMPT", "CACHE READ", "CACHE WRITE", "COMPLETION", "TOTAL") + for _, line = range usage.GetLines() { + rows.row(line.GetKind(), tokenCount(line.GetPromptTokens()), tokenCount(line.GetCachedTokens()), + tokenCount(line.GetCacheWriteTokens()), tokenCount(line.GetCompletionTokens()), tokenCount(line.GetTotalTokens())) + } + rows.row("total", tokenCount(usage.GetPromptTokens()), tokenCount(usage.GetCachedTokens()), + tokenCount(usage.GetCacheWriteTokens()), tokenCount(usage.GetCompletionTokens()), tokenCount(usage.GetTotalTokens())) + rows.flush() + + return nil +} + +func remoteSessionRemoveExecute(ctx context.Context, sessionId string) error { + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + + var err error + + connection, client, err = remoteConnect(ctx) + if err != nil { + return err + } + defer connection.Close() + + _, err = client.DeleteSession(ctx, &mininaruv1.DeleteSessionRequest{SessionId: sessionId}) + + return err +} + +func remoteSessionRenameExecute(ctx context.Context, sessionId, name string) error { + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + + var err error + + connection, client, err = remoteConnect(ctx) + if err != nil { + return err + } + defer connection.Close() + + _, err = client.RenameSession(ctx, &mininaruv1.RenameSessionRequest{SessionId: sessionId, Name: name}) + + return err +} diff --git a/cli/serve.go b/cli/serve.go index 4416f18..82b4d90 100644 --- a/cli/serve.go +++ b/cli/serve.go @@ -14,6 +14,7 @@ import ( "github.com/devproje/mininaru/config" "github.com/devproje/mininaru/core" "github.com/devproje/mininaru/modules" + mininarurpc "github.com/devproje/mininaru/rpc" "github.com/devproje/mininaru/server" "github.com/devproje/mininaru/util" "github.com/spf13/cobra" @@ -22,19 +23,23 @@ import ( const apiKeyEnv = "MININARU_API_KEY" var ( - serveHostRef string - servePortRef int - serveApiKeyRef string + serveHostRef string + servePortRef int + serveApiKeyRef string + serveGRPCHostRef string + serveGRPCPortRef int + serveGRPCOnlyRef bool ) var serve *cobra.Command = &cobra.Command{ Use: "serve", - Short: "serve the api and any configured bot front ends", - Long: `Run the OpenAI compatible HTTP API, plus every enabled bot. + Short: "serve the gRPC and HTTP APIs plus configured bot front ends", + Long: `Run the paired gRPC API, OpenAI compatible HTTP API, and every enabled bot. -The API is stateless and exposes each agent as a model name. Requests need the -bearer token from --api-key or ` + "`" + apiKeyEnv + "`" + `, and only safe tools are offered. -Send SIGHUP to reload configuration without restarting.`, +gRPC clients use server-owned sessions and paired mTLS identities. The HTTP API +is stateless, exposes each agent as a model name, requires the bearer token from +--api-key or ` + "`" + apiKeyEnv + "`" + `, and offers only safe tools. Send SIGHUP to reload +configuration without restarting.`, Example: ` mininaru serve mininaru serve --host 0.0.0.0 --port 8080`, Args: usageArgs(cobra.NoArgs), @@ -144,19 +149,50 @@ func startBots(registry *core.Registry) ([]*bot.Discord, error) { return started, nil } +func serveAll(ctx context.Context, httpConfig server.Config, grpcConfig mininarurpc.Config, registry *core.Registry) error { + var running context.Context + var cancel context.CancelFunc + var results chan error + var first error + var second error + + running, cancel = context.WithCancel(ctx) + defer cancel() + + results = make(chan error, 2) + go func() { + results <- mininarurpc.Serve(running, grpcConfig, registry) + }() + go func() { + results <- server.Serve(running, httpConfig, registry) + }() + + first = <-results + cancel() + second = <-results + + if first != nil { + return first + } + + return second +} + func serveExecute(cmd *cobra.Command, args []string) error { var cfg server.Config + var grpcCfg mininarurpc.Config var registry *core.Registry var started []*bot.Discord var err error cfg = server.Config{Host: serveHostRef, Port: servePortRef, ApiKey: serveApiKeyRef} + grpcCfg = mininarurpc.Config{Host: serveGRPCHostRef, Port: serveGRPCPortRef} if cfg.ApiKey == "" { cfg.ApiKey = os.Getenv(apiKeyEnv) } - if cfg.ApiKey == "" { + if cfg.ApiKey == "" && !serveGRPCOnlyRef { return configErrorf("api key is required, pass --api-key or set %s", apiKeyEnv) } @@ -190,14 +226,22 @@ func serveExecute(cmd *cobra.Command, args []string) error { } defer stopBots(started) + if serveGRPCOnlyRef { + uiNote("serving %d agent(s) on grpc://%s:%d", len(registry.List()), grpcCfg.Host, grpcCfg.Port) + + return mininarurpc.Serve(cmd.Context(), grpcCfg, registry) + } - uiNote("serving %d agent(s) on http://%s:%d", len(registry.List()), cfg.Host, cfg.Port) + uiNote("serving %d agent(s) on http://%s:%d and grpc://%s:%d", len(registry.List()), cfg.Host, cfg.Port, grpcCfg.Host, grpcCfg.Port) - return server.Serve(cmd.Context(), cfg, registry) + return serveAll(cmd.Context(), cfg, grpcCfg, registry) } func init() { serve.Flags().StringVar(&serveHostRef, "host", server.DefaultHost, "address to bind the api server") serve.Flags().IntVar(&servePortRef, "port", server.DefaultPort, "port to bind the api server") serve.Flags().StringVar(&serveApiKeyRef, "api-key", "", "bearer token required by api clients, defaults to "+apiKeyEnv) + serve.Flags().StringVar(&serveGRPCHostRef, "grpc-host", mininarurpc.DefaultHost, "address to bind the gRPC server") + serve.Flags().IntVar(&serveGRPCPortRef, "grpc-port", mininarurpc.DefaultPort, "port to bind the gRPC server") + serve.Flags().BoolVar(&serveGRPCOnlyRef, "grpc-only", false, "serve paired gRPC clients without starting the HTTP API") } diff --git a/cli/setup.go b/cli/setup.go index c99ad5b..ac767ec 100644 --- a/cli/setup.go +++ b/cli/setup.go @@ -20,16 +20,18 @@ import ( var setup *cobra.Command = &cobra.Command{ Use: "setup", Short: "walk through the first run configuration", - Long: `Configure a provider, an agent and the defaults in one guided pass. + Long: `Choose a paired client or server installation in one guided pass. -Existing configuration is offered back as the default at every step, so this is -safe to re-run. It needs a terminal; without one, use ` + "`provider add`" + ` and -` + "`agent add`" + ` instead.`, +Client mode verifies the remote server fingerprint and requests pairing. Server +mode configures a provider, an agent and local defaults. Existing configuration +is offered back as the default, so this is safe to re-run.`, Example: ` mininaru setup`, Args: usageArgs(cobra.NoArgs), RunE: setupExecute, } +var setupPair = pairWithServer + func setupProvider() (*core.Provider, error) { var reuse bool @@ -242,16 +244,31 @@ func setupDaemon(cmd *cobra.Command) error { return daemonInstallExecute(cmd, nil) } -func setupExecute(cmd *cobra.Command, args []string) error { - var prov *core.Provider +func setupClient(cmd *cobra.Command) error { + var address string + var name string var err error - if !askInteractive() { - return usageErrorf("setup needs a terminal, configure with `provider add` and `agent add` instead") + fmt.Fprintln(askOut, "\npair this device with a mininaru server") + + address, err = askRequired("server address (host:port)") + if err != nil { + return err + } + + name, err = askText("device name", pairDeviceName()) + if err != nil { + return err } - fmt.Fprintf(askOut, "configuring mininaru in %s\n", util.RootDir) + return setupPair(cmd.Context(), address, name, "") +} + +func setupServer(cmd *cobra.Command) error { + var prov *core.Provider + + var err error prov, err = setupProvider() if err != nil { @@ -278,3 +295,26 @@ func setupExecute(cmd *cobra.Command, args []string) error { return nil } + +func setupExecute(cmd *cobra.Command, args []string) error { + var mode string + + var err error + + if !askInteractive() { + return usageErrorf("setup needs a terminal, configure the server with `provider add` and `agent add`, or pair a client with `mininaru pair`") + } + + fmt.Fprintf(askOut, "configuring mininaru in %s\n\n", util.RootDir) + + mode, err = askChoice("mode", []string{"server", "client"}, "server") + if err != nil { + return err + } + + if mode == "client" { + return setupClient(cmd) + } + + return setupServer(cmd) +} diff --git a/cli/setup_test.go b/cli/setup_test.go index f0ad259..97a01ae 100644 --- a/cli/setup_test.go +++ b/cli/setup_test.go @@ -4,12 +4,43 @@ package main import ( + "context" "os" "path/filepath" "strings" "testing" ) +func TestSetupStartsByChoosingClientOrServer(t *testing.T) { + var previous func(context.Context, string, string, string) error + var address string + var name string + var expected string + + var err error + + fakeSession(t, "client\nnaru.example.com:9090\nlaptop\n") + previous = setupPair + setupPair = func(ctx context.Context, gotAddress, gotName, gotExpected string) error { + address = gotAddress + name = gotName + expected = gotExpected + + return nil + } + t.Cleanup(func() { + setupPair = previous + }) + + err = setupExecute(setup, nil) + if err != nil { + t.Fatal(err) + } + if address != "naru.example.com:9090" || name != "laptop" || expected != "" { + t.Fatalf("pairing input = %q, %q, %q", address, name, expected) + } +} + func TestSetupEnvFileCreatesAPrivateFile(t *testing.T) { var path string var info os.FileInfo diff --git a/cli/tui/backend.go b/cli/tui/backend.go new file mode 100644 index 0000000..fc15ecd --- /dev/null +++ b/cli/tui/backend.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package tui + +import ( + "context" + + "github.com/devproje/mininaru/core" +) + +type Backend interface { + Chat(context.Context, *core.Session, *core.NaruAgent, string, func(string), func(string), core.ToolEventFunc, core.ToolApprovalFunc) (*core.Message, error) + Compact(context.Context, *core.NaruAgent, *core.Session) (bool, error) + Usage(string) (*core.UsageTotals, error) + Context(string) (int64, int64, bool, error) + ToolCalls(string) ([]*core.ToolCall, error) +} + +type localBackend struct{} + +func (localBackend) Chat(ctx context.Context, session *core.Session, agent *core.NaruAgent, content string, + onContent, onReasoning func(string), onTool core.ToolEventFunc, approve core.ToolApprovalFunc) (*core.Message, error) { + return core.ChatWithApproval(ctx, session, agent, content, onContent, onReasoning, onTool, approve) +} + +func (localBackend) Compact(ctx context.Context, agent *core.NaruAgent, session *core.Session) (bool, error) { + return core.CompactNow(ctx, agent, session) +} + +func (localBackend) Usage(sessionId string) (*core.UsageTotals, error) { + return core.SessionUsage(sessionId) +} + +func (localBackend) Context(sessionId string) (int64, int64, bool, error) { + return core.SessionContextTokens(sessionId) +} + +func (localBackend) ToolCalls(messageId string) ([]*core.ToolCall, error) { + return core.ToolCallList(messageId) +} diff --git a/cli/tui/client.go b/cli/tui/client.go index 2535b8f..423d127 100644 --- a/cli/tui/client.go +++ b/cli/tui/client.go @@ -74,6 +74,7 @@ type slashCommand struct { type client struct { session *core.Session agent *core.NaruAgent + backend Backend program *tea.Program input textarea.Model @@ -171,7 +172,7 @@ func (c *client) recordApproval(name string, decision approvalDecision) bool { return decision != approvalDeny } -func newClient(session *core.Session, agent *core.NaruAgent, history []*core.Message) *client { +func newClientWithBackend(session *core.Session, agent *core.NaruAgent, history []*core.Message, backend Backend) *client { var input textarea.Model var sp spinner.Model var view viewport.Model @@ -204,6 +205,7 @@ func newClient(session *core.Session, agent *core.NaruAgent, history []*core.Mes c = client{ session: session, agent: agent, + backend: backend, input: input, spinner: sp, view: view, @@ -222,7 +224,7 @@ func newClient(session *core.Session, agent *core.NaruAgent, history []*core.Mes if cur.Role != "user" { continue } - calls, err = core.ToolCallList(cur.Id) + calls, err = c.backend.ToolCalls(cur.Id) if err != nil { c.transcript = append(c.transcript, transcriptEntry{kind: transcriptNotice, content: "tool log error: " + err.Error()}) continue @@ -239,7 +241,11 @@ func newClient(session *core.Session, agent *core.NaruAgent, history []*core.Mes return &c } -func Run(session *core.Session, agent *core.NaruAgent, history []*core.Message, notice string) error { +func newClient(session *core.Session, agent *core.NaruAgent, history []*core.Message) *client { + return newClientWithBackend(session, agent, history, localBackend{}) +} + +func RunWithBackend(session *core.Session, agent *core.NaruAgent, history []*core.Message, notice string, backend Backend) error { var c *client var p *tea.Program var release func() @@ -247,11 +253,11 @@ func Run(session *core.Session, agent *core.NaruAgent, history []*core.Message, var err error agent.ModelContextWindow(context.Background()) - c = newClient(session, agent, history) + if backend == nil { + backend = localBackend{} + } + c = newClientWithBackend(session, agent, history, backend) c.notice = notice - // Do not enable terminal mouse reporting here. It steals ordinary drag - // selection from the terminal, which prevents users from copying chat text - // with the mouse. Keyboard scrolling remains available via PageUp/PageDown. p = tea.NewProgram(c, tea.WithAltScreen()) c.program = p @@ -268,6 +274,10 @@ func Run(session *core.Session, agent *core.NaruAgent, history []*core.Message, return err } +func Run(session *core.Session, agent *core.NaruAgent, history []*core.Message, notice string) error { + return RunWithBackend(session, agent, history, notice, localBackend{}) +} + func (c *client) contentWidth() int { if c.width < 12 { return 4 @@ -388,7 +398,7 @@ func (c *client) sendPrompt(ctx context.Context, content string) tea.Cmd { var err error - message, err = core.ChatWithApproval(ctx, c.session, c.agent, content, func(delta string) { + message, err = c.backend.Chat(ctx, c.session, c.agent, content, func(delta string) { c.program.Send(chatDeltaMsg(delta)) }, func(delta string) { c.program.Send(chatThinkMsg(delta)) @@ -561,7 +571,7 @@ func (c *client) compactRun(ctx context.Context) tea.Cmd { var err error - compacted, err = core.CompactNow(ctx, c.agent, c.session) + compacted, err = c.backend.Compact(ctx, c.agent, c.session) return compactDoneMsg{compacted: compacted, err: err} } @@ -616,7 +626,7 @@ func (c *client) usageCommand() tea.Cmd { var err error - totals, err = core.SessionUsage(c.session.Id) + totals, err = c.backend.Usage(c.session.Id) if err != nil { c.transcript = append(c.transcript, transcriptEntry{kind: transcriptNotice, content: "could not read token usage: " + err.Error()}) @@ -1482,7 +1492,7 @@ func (c *client) refreshContextUsage() { var err error - tokens, window, known, err = core.SessionContextTokens(c.session.Id) + tokens, window, known, err = c.backend.Context(c.session.Id) if err != nil { return } diff --git a/config/client.go b/config/client.go index 38e264c..28fc62a 100644 --- a/config/client.go +++ b/config/client.go @@ -28,11 +28,16 @@ type Update struct { Check bool `json:"check"` } +type Server struct { + Address string `json:"address"` +} + type ClientConfig struct { Thinking Thinking `json:"thinking"` Context Context `json:"context"` Tools Tools `json:"tools"` Update Update `json:"update"` + Server Server `json:"server"` } const CLIENT_PATH = "client.json" diff --git a/core/chat.go b/core/chat.go index 845b492..85d183d 100644 --- a/core/chat.go +++ b/core/chat.go @@ -185,7 +185,7 @@ func deltaReasoning(delta openai.ChatCompletionChunkChoiceDelta) string { } func chatWithToolPolicy(ctx context.Context, session *Session, agent *NaruAgent, content string, parts []openai.ChatCompletionContentPartUnionParam, - defs []modules.Def, onContent, onReasoning func(string), onTool ToolEventFunc, approve ToolApprovalFunc, allowDangerous bool) (*Message, error) { + defs []modules.Def, thinking string, onContent, onReasoning func(string), onTool ToolEventFunc, approve ToolApprovalFunc, allowDangerous bool) (*Message, error) { var history []*Message var calls map[string][]*ToolCall var prompt string @@ -248,8 +248,8 @@ func chatWithToolPolicy(ctx context.Context, session *Session, agent *NaruAgent, params.Tools = toolParams(defs) } - if config.ThinkingEnabled() { - params.ReasoningEffort = openai.ReasoningEffort(config.Client.Thinking.Level) + if thinking != "" && thinking != config.ThinkingOff { + params.ReasoningEffort = openai.ReasoningEffort(thinking) } pending, err = messageStart(session.Id, content) @@ -285,7 +285,7 @@ func chatWithToolPolicy(ctx context.Context, session *Session, agent *NaruAgent, } func chatWithTools(ctx context.Context, session *Session, agent *NaruAgent, content string, defs []modules.Def, onContent, onReasoning func(string), onTool ToolEventFunc, approve ToolApprovalFunc) (*Message, error) { - return chatWithToolPolicy(ctx, session, agent, content, nil, defs, onContent, onReasoning, onTool, approve, config.AllowDangerousTools) + return chatWithToolPolicy(ctx, session, agent, content, nil, defs, config.Client.Thinking.Level, onContent, onReasoning, onTool, approve, config.AllowDangerousTools) } func Chat(ctx context.Context, session *Session, agent *NaruAgent, content string, onContent, onReasoning func(string)) (*Message, error) { diff --git a/core/instance.go b/core/instance.go index 16115ec..14a1f20 100644 --- a/core/instance.go +++ b/core/instance.go @@ -8,6 +8,7 @@ import ( "fmt" "sync" + "github.com/devproje/mininaru/config" "github.com/devproje/mininaru/modules" "github.com/openai/openai-go" ) @@ -93,11 +94,11 @@ func (i *Instance) Chat(ctx context.Context, session *Session, content string, } defer i.locks.release(session.Id) - return chatWithToolPolicy(ctx, session, i.Agent, content, nil, i.Tools, onContent, onReasoning, onTool, nil, false) + return chatWithToolPolicy(ctx, session, i.Agent, content, nil, i.Tools, config.Client.Thinking.Level, onContent, onReasoning, onTool, nil, false) } -func (i *Instance) ChatWithTools(ctx context.Context, session *Session, content string, defs []modules.Def, - onReasoning func(string), onTool ToolEventFunc, approve ToolApprovalFunc) (*Message, error) { +func (i *Instance) ChatWithTools(ctx context.Context, session *Session, content string, defs []modules.Def, thinking string, + onContent, onReasoning func(string), onTool ToolEventFunc, approve ToolApprovalFunc) (*Message, error) { var err error if session == nil { @@ -112,7 +113,7 @@ func (i *Instance) ChatWithTools(ctx context.Context, session *Session, content } defer i.locks.release(session.Id) - return chatWithToolPolicy(ctx, session, i.Agent, content, nil, defs, nil, onReasoning, onTool, approve, false) + return chatWithToolPolicy(ctx, session, i.Agent, content, nil, defs, thinking, onContent, onReasoning, onTool, approve, false) } func (i *Instance) ChatInput(ctx context.Context, session *Session, content string, parts []openai.ChatCompletionContentPartUnionParam, @@ -131,7 +132,7 @@ func (i *Instance) ChatInput(ctx context.Context, session *Session, content stri } defer i.locks.release(session.Id) - return chatWithToolPolicy(ctx, session, i.Agent, content, parts, defs, nil, onReasoning, onTool, approve, false) + return chatWithToolPolicy(ctx, session, i.Agent, content, parts, defs, config.Client.Thinking.Level, nil, onReasoning, onTool, approve, false) } func (i *Instance) Session(name string) (*Session, error) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8cf0f62..51c5a27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,9 @@ # Architecture mininaru is a single Go binary that talks to OpenAI-compatible providers and -the native Anthropic Messages API. It -ships two front ends over one core: a terminal chat client and an HTTP API. +the native Anthropic Messages API. It ships a local terminal client, a paired +session-aware gRPC client, and a stateless OpenAI-compatible HTTP API over one +core. ## Packages @@ -12,16 +13,17 @@ core/ providers, agents, sessions, messages, the tool-calling loop modules/ tool implementations, the in-process MCP server exposing them, the MCP client manager, mcp.json, web.json, and skill discovery server/ stateless OpenAI-compatible HTTP API +rpc/ protobuf contract, paired mTLS transport, session API, and PKI bot/ chat front ends that live inside the daemon (Discord) config/ client.json preferences (thinking, context budget, tool switch) cli/tui/ interactive terminal model, rendering, input, and approvals util/ data directory layout, SQLite handle, migrations, version info ``` -Dependencies point one way: `cli` depends on `server` and `bot`, both of which -depend on `core`, and `core` depends on `modules`, `config`, and `util`. Nothing -in `core` imports its callers, and `server` and `bot` do not import each other — -`cli/serve.go` is the only place that knows about both. `modules` stays a leaf: +Dependencies point one way: `cli` depends on `server`, `rpc`, and `bot`, all of +which depend on `core`, and `core` depends on `modules`, `config`, and `util`. +Nothing in `core` imports its callers, and the front ends do not import each +other — `cli/serve.go` is the only place that knows about them. `modules` stays a leaf: it imports `util` and the MCP SDK and nothing else in the tree. MCP process lifetime is owned by `cli`; `core` never starts or stops anything. @@ -37,12 +39,12 @@ Every front end drives the same tool-calling loop, `completionRun` in model keeps emitting tool calls it executes them and feeds the results back, up to `maxToolRounds` (8) times. -| | `core.Chat` (TUI, `-p`) | `core.Complete` (server) | +| | `core.Chat` (local and gRPC TUI, `-p`) | `core.Complete` (HTTP server) | |---|---|---| -| History | loaded from SQLite by session | supplied in the request | +| History | loaded from server-side SQLite by session | supplied in the request | | Persistence | messages and tool calls written | nothing written | | Tools | `modules.DefaultTools()` | `modules.SafeTools()` | -| Dangerous tools | approval callback, or `--allow-dangerous-tools` | never offered | +| Dangerous tools | local or gRPC approval callback | never offered | | Context management | provider token usage and model window drive `compactHistory` | client's responsibility | | Token accounting | recorded against the session | returned in the response | @@ -733,10 +735,56 @@ carried as `reasoning_content` deltas. Once the stream has started the status code is already sent, so a mid-stream failure arrives as an `[error]` content delta followed by `data: [DONE]`. +## Native gRPC + +The native API in `api/mininaru/v1/mininaru.proto` is deliberately separate +from the OpenAI-compatible HTTP surface. HTTP callers supply their whole +history and receive only safe tools. A paired gRPC client names a server-owned +session and drives `Instance.ChatWithTools`, so it receives persisted history, +reasoning deltas, tool events, approval requests, compaction, and usage from the +same path as the local TUI. + +The `Chat` RPC is bidirectional. Its first client event must be `start`; later +client events answer a tool approval or cancel the turn. Server events carry +content, reasoning, tool progress, an approval request, and exactly one +terminal completion or failure. Losing the HTTP/2 stream cancels the core +context, so a disconnected client cannot leave a model turn running. + +Pairing and normal RPCs share a TLS 1.3 listener but not an authorization +policy. `PairingService` accepts a connection without a client certificate, +rate limits `Begin` by peer address, and addresses a pending request by an +unguessable UUID. Every `MininaruService` unary and streaming call passes an +interceptor that requires a CA-verified client certificate and then checks its +public-key fingerprint, certificate serial, and revocation state in SQLite. + +The client verifies the server twice. Before pairing, the operator compares the +server public-key fingerprint through another trusted channel. Afterwards the +client pins that fingerprint and also verifies the server certificate against +the CA returned by the approved pairing. Renewal can therefore keep the server +key without another trust prompt, while a changed key fails closed. + +The CA and server key are generated as Ed25519 keys under `pki/`; each client +generates its own Ed25519 key and sends only its DER public key. Approval signs +a one-year client certificate, stores its serial and fingerprint, and makes a +previous certificate for the same key unusable. Revocation is checked on every +RPC rather than only during the TLS handshake, so it also applies to an +existing connection. + +`rpc_clients` and `rpc_pairings` are introduced by migration 0014. Pairing +codes expire after five minutes. The code only identifies a request for the +operator; the UUID held by the waiting client is the capability used to collect +the issued certificate. + +Generated Go sources live under `rpc/gen`. `make generate` runs the pinned +protobuf toolchain supplied by the developer, adds the repository SPDX header, +and strips generator comments so generated files still follow the project +comment convention. + ## Development ```sh make build # -> out/mininaru +make generate # regenerate protobuf and gRPC sources make test # gofmt -l, go vet, go test ./... make install # scripts/binary-install.sh ``` diff --git a/go.mod b/go.mod index 04750d1..ecd5d6a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,9 @@ require ( go.yaml.in/yaml/v3 v3.0.5 golang.org/x/net v0.58.0 golang.org/x/term v0.45.0 + golang.org/x/tools v0.48.0 + google.golang.org/grpc v1.83.0 + google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.56.0 ) @@ -69,11 +72,13 @@ require ( github.com/yuin/goldmark-emoji v1.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.55.0 // indirect - golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 2e2913f..a6b1052 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJ github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= @@ -59,8 +61,14 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= @@ -150,6 +158,18 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= @@ -165,8 +185,8 @@ golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -184,6 +204,14 @@ golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/rpc/auth.go b/rpc/auth.go new file mode 100644 index 0000000..0837bee --- /dev/null +++ b/rpc/auth.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "crypto/x509" + "strings" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +const pairingMethodPrefix = "/mininaru.v1.PairingService/" + +func clientCertificate(ctx context.Context) (*x509.Certificate, error) { + var remote *peer.Peer + var found bool + var info credentials.TLSInfo + var ok bool + + remote, found = peer.FromContext(ctx) + if !found { + return nil, status.Error(codes.Unauthenticated, "client certificate is required") + } + + info, ok = remote.AuthInfo.(credentials.TLSInfo) + if !ok || len(info.State.PeerCertificates) == 0 || len(info.State.VerifiedChains) == 0 { + return nil, status.Error(codes.Unauthenticated, "valid client certificate is required") + } + + return info.State.PeerCertificates[0], nil +} + +func authenticate(ctx context.Context) error { + var certificate *x509.Certificate + + var err error + + certificate, err = clientCertificate(ctx) + if err != nil { + return err + } + + _, err = ClientAuthenticate(certificate) + if err != nil { + return status.Error(codes.PermissionDenied, err.Error()) + } + + return nil +} + +func unaryAuthenticate(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + var err error + + if strings.HasPrefix(info.FullMethod, pairingMethodPrefix) { + return handler(ctx, request) + } + + err = authenticate(ctx) + if err != nil { + return nil, err + } + + return handler(ctx, request) +} + +func streamAuthenticate(server any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { + var err error + + if strings.HasPrefix(info.FullMethod, pairingMethodPrefix) { + return handler(server, stream) + } + + err = authenticate(stream.Context()) + if err != nil { + return err + } + + return handler(server, stream) +} diff --git a/rpc/client.go b/rpc/client.go new file mode 100644 index 0000000..df6f0f1 --- /dev/null +++ b/rpc/client.go @@ -0,0 +1,388 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "time" + + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "github.com/devproje/mininaru/util" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type KnownServer struct { + Address string `json:"address"` + Fingerprint string `json:"fingerprint"` + Certificate string `json:"certificate"` + PrivateKey string `json:"private_key"` + CA string `json:"ca"` +} + +type KnownServers struct { + Servers []*KnownServer `json:"servers"` +} + +const knownServersFile = "servers.json" + +const identityDirectory = "identity" + +const dialTimeout = 10 * time.Second + +func serverKey(address string) string { + var sum [sha256.Size]byte + + sum = sha256.Sum256([]byte(address)) + + return hex.EncodeToString(sum[:8]) +} + +func loadKnownServers() (*KnownServers, error) { + var servers KnownServers + var bytes []byte + + var err error + + bytes, err = os.ReadFile(util.Path(knownServersFile)) + if err != nil { + if os.IsNotExist(err) { + return &servers, nil + } + + return nil, err + } + + err = json.Unmarshal(bytes, &servers) + if err != nil { + return nil, err + } + + return &servers, nil +} + +func knownServer(address string) (*KnownServer, error) { + var servers *KnownServers + var server *KnownServer + + var err error + + servers, err = loadKnownServers() + if err != nil { + return nil, err + } + + for _, server = range servers.Servers { + if server.Address == address { + return server, nil + } + } + + return nil, fmt.Errorf("server %s is not paired", address) +} + +func saveKnownServer(server *KnownServer) error { + var servers *KnownServers + var current *KnownServer + var found bool + var bytes []byte + + var err error + + servers, err = loadKnownServers() + if err != nil { + return err + } + + for _, current = range servers.Servers { + if current.Address != server.Address { + continue + } + + *current = *server + found = true + break + } + if !found { + servers.Servers = append(servers.Servers, server) + } + + bytes, err = json.MarshalIndent(servers, "", " ") + if err != nil { + return err + } + + return util.WriteFileAtomic(util.Path(knownServersFile), bytes, 0600) +} + +func peerFingerprint(state tls.ConnectionState) (string, error) { + if len(state.PeerCertificates) == 0 { + return "", fmt.Errorf("server sent no certificate") + } + + return certificateFingerprint(state.PeerCertificates[0]) +} + +func fingerprintTLS(expected string) *tls.Config { + var config tls.Config + + config.MinVersion = tls.VersionTLS13 + config.InsecureSkipVerify = true + config.VerifyConnection = func(state tls.ConnectionState) error { + var fingerprint string + + var err error + + fingerprint, err = peerFingerprint(state) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(fingerprint), []byte(expected)) != 1 { + return fmt.Errorf("server fingerprint changed: got %s", fingerprint) + } + if time.Now().Before(state.PeerCertificates[0].NotBefore) || time.Now().After(state.PeerCertificates[0].NotAfter) { + return fmt.Errorf("server certificate is not currently valid") + } + + return nil + } + + return &config +} + +func ServerFingerprint(ctx context.Context, address string) (string, error) { + var dialer net.Dialer + var config tls.Config + var tlsDialer tls.Dialer + var raw net.Conn + var connection *tls.Conn + var state tls.ConnectionState + var ok bool + + var err error + + config.MinVersion = tls.VersionTLS13 + config.InsecureSkipVerify = true + dialer.Timeout = dialTimeout + tlsDialer.NetDialer = &dialer + tlsDialer.Config = &config + + raw, err = tlsDialer.DialContext(ctx, "tcp", address) + if err != nil { + return "", err + } + connection, ok = raw.(*tls.Conn) + if !ok { + raw.Close() + return "", fmt.Errorf("server connection is not tls") + } + defer connection.Close() + + state = connection.ConnectionState() + + return peerFingerprint(state) +} + +func pairingConnection(address, fingerprint string) (*grpc.ClientConn, error) { + return grpc.NewClient(address, + grpc.WithTransportCredentials(credentials.NewTLS(fingerprintTLS(fingerprint))), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxReceiveMessageBytes), grpc.MaxCallSendMsgSize(maxSendMessageBytes))) +} + +func encodePrivateKey(privateKey ed25519.PrivateKey) ([]byte, error) { + var encoded []byte + + var err error + + encoded, err = x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return nil, err + } + + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: encoded}), nil +} + +func saveClientIdentity(address, fingerprint string, privateKey ed25519.PrivateKey, certificate, ca []byte) error { + var directory string + var key string + var certificatePath string + var privateKeyPath string + var caPath string + var privateKeyPEM []byte + var server KnownServer + + var err error + + directory = util.Path(identityDirectory) + err = os.MkdirAll(directory, 0700) + if err != nil { + return err + } + + key = serverKey(address) + certificatePath = filepath.Join(directory, key+".crt") + privateKeyPath = filepath.Join(directory, key+".key") + caPath = filepath.Join(directory, key+"-ca.crt") + + privateKeyPEM, err = encodePrivateKey(privateKey) + if err != nil { + return err + } + + err = util.WriteFileAtomic(certificatePath, certificate, 0600) + if err != nil { + return err + } + err = util.WriteFileAtomic(privateKeyPath, privateKeyPEM, 0600) + if err != nil { + return err + } + err = util.WriteFileAtomic(caPath, ca, 0600) + if err != nil { + return err + } + + server = KnownServer{Address: address, Fingerprint: fingerprint, + Certificate: certificatePath, PrivateKey: privateKeyPath, CA: caPath} + + return saveKnownServer(&server) +} + +func Pair(ctx context.Context, address, deviceName, fingerprint string, + onBegin func(*PairingRequest)) error { + var publicKey ed25519.PublicKey + var privateKey ed25519.PrivateKey + var encodedPublicKey []byte + var connection *grpc.ClientConn + var client mininaruv1.PairingServiceClient + var response *mininaruv1.BeginPairingResponse + var request PairingRequest + var watch mininaruv1.PairingService_WatchClient + var event *mininaruv1.PairingEvent + + var err error + + publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + encodedPublicKey, err = x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return err + } + + connection, err = pairingConnection(address, fingerprint) + if err != nil { + return err + } + defer connection.Close() + + client = mininaruv1.NewPairingServiceClient(connection) + response, err = client.Begin(ctx, &mininaruv1.BeginPairingRequest{PublicKey: encodedPublicKey, DeviceName: deviceName}) + if err != nil { + return err + } + + request = PairingRequest{Id: response.GetRequestId(), Code: response.GetPairingCode(), Name: deviceName, + Fingerprint: response.GetClientFingerprint(), ExpiresAt: response.GetExpiresAtUnix(), Status: pairingWaiting} + if onBegin != nil { + onBegin(&request) + } + + watch, err = client.Watch(ctx, &mininaruv1.WatchPairingRequest{RequestId: response.GetRequestId()}) + if err != nil { + return err + } + + for { + event, err = watch.Recv() + if err != nil { + return err + } + switch event.GetState() { + case mininaruv1.PairingState_PAIRING_STATE_WAITING: + continue + case mininaruv1.PairingState_PAIRING_STATE_APPROVED: + return saveClientIdentity(address, fingerprint, privateKey, + event.GetClientCertificatePem(), event.GetCaCertificatePem()) + case mininaruv1.PairingState_PAIRING_STATE_DENIED: + return fmt.Errorf("pairing request was denied") + default: + return fmt.Errorf("pairing request expired") + } + } +} + +func verifyKnownServer(server *KnownServer, roots *x509.CertPool) func(tls.ConnectionState) error { + return func(state tls.ConnectionState) error { + var fingerprint string + var options x509.VerifyOptions + + var err error + + fingerprint, err = peerFingerprint(state) + if err != nil { + return err + } + if subtle.ConstantTimeCompare([]byte(fingerprint), []byte(server.Fingerprint)) != 1 { + return fmt.Errorf("server fingerprint changed: got %s", fingerprint) + } + + options = x509.VerifyOptions{Roots: roots, KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}} + _, err = state.PeerCertificates[0].Verify(options) + + return err + } +} + +func Dial(ctx context.Context, address string) (*grpc.ClientConn, error) { + var server *KnownServer + var certificate tls.Certificate + var ca []byte + var roots *x509.CertPool + var config tls.Config + + var err error + + address = strings.TrimSpace(address) + server, err = knownServer(address) + if err != nil { + return nil, err + } + + certificate, err = tls.LoadX509KeyPair(server.Certificate, server.PrivateKey) + if err != nil { + return nil, err + } + ca, err = os.ReadFile(server.CA) + if err != nil { + return nil, err + } + + roots = x509.NewCertPool() + if !roots.AppendCertsFromPEM(ca) { + return nil, fmt.Errorf("load paired server ca") + } + + config = tls.Config{Certificates: []tls.Certificate{certificate}, MinVersion: tls.VersionTLS13, + InsecureSkipVerify: true, VerifyConnection: verifyKnownServer(server, roots)} + + return grpc.NewClient(address, + grpc.WithTransportCredentials(credentials.NewTLS(&config)), + grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxReceiveMessageBytes), grpc.MaxCallSendMsgSize(maxSendMessageBytes))) +} diff --git a/rpc/gen/mininaru/v1/mininaru.pb.go b/rpc/gen/mininaru/v1/mininaru.pb.go new file mode 100644 index 0000000..1ff5034 --- /dev/null +++ b/rpc/gen/mininaru/v1/mininaru.pb.go @@ -0,0 +1,2853 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package mininaruv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type PairingState int32 + +const ( + PairingState_PAIRING_STATE_UNSPECIFIED PairingState = 0 + PairingState_PAIRING_STATE_WAITING PairingState = 1 + PairingState_PAIRING_STATE_APPROVED PairingState = 2 + PairingState_PAIRING_STATE_DENIED PairingState = 3 + PairingState_PAIRING_STATE_EXPIRED PairingState = 4 +) + +var ( + PairingState_name = map[int32]string{ + 0: "PAIRING_STATE_UNSPECIFIED", + 1: "PAIRING_STATE_WAITING", + 2: "PAIRING_STATE_APPROVED", + 3: "PAIRING_STATE_DENIED", + 4: "PAIRING_STATE_EXPIRED", + } + PairingState_value = map[string]int32{ + "PAIRING_STATE_UNSPECIFIED": 0, + "PAIRING_STATE_WAITING": 1, + "PAIRING_STATE_APPROVED": 2, + "PAIRING_STATE_DENIED": 3, + "PAIRING_STATE_EXPIRED": 4, + } +) + +func (x PairingState) Enum() *PairingState { + var p *PairingState + + p = new(PairingState) + *p = x + return p +} + +func (x PairingState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (PairingState) Descriptor() protoreflect.EnumDescriptor { + return file_mininaru_v1_mininaru_proto_enumTypes[0].Descriptor() +} + +func (PairingState) Type() protoreflect.EnumType { + return &file_mininaru_v1_mininaru_proto_enumTypes[0] +} + +func (x PairingState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +func (PairingState) EnumDescriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{0} +} + +type ApprovalChoice int32 + +const ( + ApprovalChoice_APPROVAL_CHOICE_UNSPECIFIED ApprovalChoice = 0 + ApprovalChoice_APPROVAL_CHOICE_DENY ApprovalChoice = 1 + ApprovalChoice_APPROVAL_CHOICE_ONCE ApprovalChoice = 2 + ApprovalChoice_APPROVAL_CHOICE_SESSION ApprovalChoice = 3 +) + +var ( + ApprovalChoice_name = map[int32]string{ + 0: "APPROVAL_CHOICE_UNSPECIFIED", + 1: "APPROVAL_CHOICE_DENY", + 2: "APPROVAL_CHOICE_ONCE", + 3: "APPROVAL_CHOICE_SESSION", + } + ApprovalChoice_value = map[string]int32{ + "APPROVAL_CHOICE_UNSPECIFIED": 0, + "APPROVAL_CHOICE_DENY": 1, + "APPROVAL_CHOICE_ONCE": 2, + "APPROVAL_CHOICE_SESSION": 3, + } +) + +func (x ApprovalChoice) Enum() *ApprovalChoice { + var p *ApprovalChoice + + p = new(ApprovalChoice) + *p = x + return p +} + +func (x ApprovalChoice) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (ApprovalChoice) Descriptor() protoreflect.EnumDescriptor { + return file_mininaru_v1_mininaru_proto_enumTypes[1].Descriptor() +} + +func (ApprovalChoice) Type() protoreflect.EnumType { + return &file_mininaru_v1_mininaru_proto_enumTypes[1] +} + +func (x ApprovalChoice) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +func (ApprovalChoice) EnumDescriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{1} +} + +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = Empty{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[0] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[0] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*Empty) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{0} +} + +type BeginPairingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PublicKey []byte `protobuf:"bytes,1,opt,name=public_key,json=publicKey,proto3" json:"public_key,omitempty"` + DeviceName string `protobuf:"bytes,2,opt,name=device_name,json=deviceName,proto3" json:"device_name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BeginPairingRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = BeginPairingRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[1] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BeginPairingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BeginPairingRequest) ProtoMessage() {} + +func (x *BeginPairingRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[1] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*BeginPairingRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{1} +} + +func (x *BeginPairingRequest) GetPublicKey() []byte { + if x != nil { + return x.PublicKey + } + return nil +} + +func (x *BeginPairingRequest) GetDeviceName() string { + if x != nil { + return x.DeviceName + } + return "" +} + +type BeginPairingResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + PairingCode string `protobuf:"bytes,2,opt,name=pairing_code,json=pairingCode,proto3" json:"pairing_code,omitempty"` + ClientFingerprint string `protobuf:"bytes,3,opt,name=client_fingerprint,json=clientFingerprint,proto3" json:"client_fingerprint,omitempty"` + ExpiresAtUnix int64 `protobuf:"varint,4,opt,name=expires_at_unix,json=expiresAtUnix,proto3" json:"expires_at_unix,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BeginPairingResponse) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = BeginPairingResponse{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[2] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BeginPairingResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BeginPairingResponse) ProtoMessage() {} + +func (x *BeginPairingResponse) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[2] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*BeginPairingResponse) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{2} +} + +func (x *BeginPairingResponse) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *BeginPairingResponse) GetPairingCode() string { + if x != nil { + return x.PairingCode + } + return "" +} + +func (x *BeginPairingResponse) GetClientFingerprint() string { + if x != nil { + return x.ClientFingerprint + } + return "" +} + +func (x *BeginPairingResponse) GetExpiresAtUnix() int64 { + if x != nil { + return x.ExpiresAtUnix + } + return 0 +} + +type WatchPairingRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchPairingRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = WatchPairingRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[3] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchPairingRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchPairingRequest) ProtoMessage() {} + +func (x *WatchPairingRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[3] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*WatchPairingRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{3} +} + +func (x *WatchPairingRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +type PairingEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + State PairingState `protobuf:"varint,1,opt,name=state,proto3,enum=mininaru.v1.PairingState" json:"state,omitempty"` + ClientCertificatePem []byte `protobuf:"bytes,2,opt,name=client_certificate_pem,json=clientCertificatePem,proto3" json:"client_certificate_pem,omitempty"` + CaCertificatePem []byte `protobuf:"bytes,3,opt,name=ca_certificate_pem,json=caCertificatePem,proto3" json:"ca_certificate_pem,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PairingEvent) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = PairingEvent{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[4] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PairingEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PairingEvent) ProtoMessage() {} + +func (x *PairingEvent) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[4] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*PairingEvent) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{4} +} + +func (x *PairingEvent) GetState() PairingState { + if x != nil { + return x.State + } + return PairingState_PAIRING_STATE_UNSPECIFIED +} + +func (x *PairingEvent) GetClientCertificatePem() []byte { + if x != nil { + return x.ClientCertificatePem + } + return nil +} + +func (x *PairingEvent) GetCaCertificatePem() []byte { + if x != nil { + return x.CaCertificatePem + } + return nil +} + +func (x *PairingEvent) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type Agent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Model string `protobuf:"bytes,3,opt,name=model,proto3" json:"model,omitempty"` + Provider string `protobuf:"bytes,4,opt,name=provider,proto3" json:"provider,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Agent) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = Agent{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[5] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Agent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Agent) ProtoMessage() {} + +func (x *Agent) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[5] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*Agent) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{5} +} + +func (x *Agent) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Agent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Agent) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *Agent) GetProvider() string { + if x != nil { + return x.Provider + } + return "" +} + +type Session struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Session) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = Session{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[6] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[6] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*Session) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{6} +} + +func (x *Session) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Session) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *Session) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type Message struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` + Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"` + Reasoning string `protobuf:"bytes,5,opt,name=reasoning,proto3" json:"reasoning,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Message) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = Message{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[7] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Message) ProtoMessage() {} + +func (x *Message) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[7] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*Message) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{7} +} + +func (x *Message) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Message) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Message) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *Message) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *Message) GetReasoning() string { + if x != nil { + return x.Reasoning + } + return "" +} + +func (x *Message) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *Message) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ToolCall struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + CallId string `protobuf:"bytes,2,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + MessageId string `protobuf:"bytes,3,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Arguments string `protobuf:"bytes,5,opt,name=arguments,proto3" json:"arguments,omitempty"` + Result string `protobuf:"bytes,6,opt,name=result,proto3" json:"result,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` + Error string `protobuf:"bytes,8,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolCall) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ToolCall{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[8] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolCall) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolCall) ProtoMessage() {} + +func (x *ToolCall) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[8] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ToolCall) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{8} +} + +func (x *ToolCall) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ToolCall) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ToolCall) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *ToolCall) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolCall) GetArguments() string { + if x != nil { + return x.Arguments + } + return "" +} + +func (x *ToolCall) GetResult() string { + if x != nil { + return x.Result + } + return "" +} + +func (x *ToolCall) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ToolCall) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type UsageLine struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind string `protobuf:"bytes,1,opt,name=kind,proto3" json:"kind,omitempty"` + PromptTokens int64 `protobuf:"varint,2,opt,name=prompt_tokens,json=promptTokens,proto3" json:"prompt_tokens,omitempty"` + CompletionTokens int64 `protobuf:"varint,3,opt,name=completion_tokens,json=completionTokens,proto3" json:"completion_tokens,omitempty"` + TotalTokens int64 `protobuf:"varint,4,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + CachedTokens int64 `protobuf:"varint,5,opt,name=cached_tokens,json=cachedTokens,proto3" json:"cached_tokens,omitempty"` + CacheWriteTokens int64 `protobuf:"varint,6,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3" json:"cache_write_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UsageLine) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = UsageLine{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[9] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UsageLine) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UsageLine) ProtoMessage() {} + +func (x *UsageLine) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[9] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*UsageLine) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{9} +} + +func (x *UsageLine) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *UsageLine) GetPromptTokens() int64 { + if x != nil { + return x.PromptTokens + } + return 0 +} + +func (x *UsageLine) GetCompletionTokens() int64 { + if x != nil { + return x.CompletionTokens + } + return 0 +} + +func (x *UsageLine) GetTotalTokens() int64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *UsageLine) GetCachedTokens() int64 { + if x != nil { + return x.CachedTokens + } + return 0 +} + +func (x *UsageLine) GetCacheWriteTokens() int64 { + if x != nil { + return x.CacheWriteTokens + } + return 0 +} + +type Usage struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Lines []*UsageLine `protobuf:"bytes,2,rep,name=lines,proto3" json:"lines,omitempty"` + PromptTokens int64 `protobuf:"varint,3,opt,name=prompt_tokens,json=promptTokens,proto3" json:"prompt_tokens,omitempty"` + CompletionTokens int64 `protobuf:"varint,4,opt,name=completion_tokens,json=completionTokens,proto3" json:"completion_tokens,omitempty"` + TotalTokens int64 `protobuf:"varint,5,opt,name=total_tokens,json=totalTokens,proto3" json:"total_tokens,omitempty"` + CachedTokens int64 `protobuf:"varint,6,opt,name=cached_tokens,json=cachedTokens,proto3" json:"cached_tokens,omitempty"` + CacheWriteTokens int64 `protobuf:"varint,7,opt,name=cache_write_tokens,json=cacheWriteTokens,proto3" json:"cache_write_tokens,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Usage) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = Usage{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[10] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Usage) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Usage) ProtoMessage() {} + +func (x *Usage) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[10] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*Usage) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{10} +} + +func (x *Usage) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *Usage) GetLines() []*UsageLine { + if x != nil { + return x.Lines + } + return nil +} + +func (x *Usage) GetPromptTokens() int64 { + if x != nil { + return x.PromptTokens + } + return 0 +} + +func (x *Usage) GetCompletionTokens() int64 { + if x != nil { + return x.CompletionTokens + } + return 0 +} + +func (x *Usage) GetTotalTokens() int64 { + if x != nil { + return x.TotalTokens + } + return 0 +} + +func (x *Usage) GetCachedTokens() int64 { + if x != nil { + return x.CachedTokens + } + return 0 +} + +func (x *Usage) GetCacheWriteTokens() int64 { + if x != nil { + return x.CacheWriteTokens + } + return 0 +} + +type ListAgentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentsRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ListAgentsRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[11] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsRequest) ProtoMessage() {} + +func (x *ListAgentsRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[11] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ListAgentsRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{11} +} + +type ListAgentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Agents []*Agent `protobuf:"bytes,1,rep,name=agents,proto3" json:"agents,omitempty"` + DefaultAgentId string `protobuf:"bytes,2,opt,name=default_agent_id,json=defaultAgentId,proto3" json:"default_agent_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentsResponse) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ListAgentsResponse{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[12] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentsResponse) ProtoMessage() {} + +func (x *ListAgentsResponse) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[12] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ListAgentsResponse) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{12} +} + +func (x *ListAgentsResponse) GetAgents() []*Agent { + if x != nil { + return x.Agents + } + return nil +} + +func (x *ListAgentsResponse) GetDefaultAgentId() string { + if x != nil { + return x.DefaultAgentId + } + return "" +} + +type ListSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Agent string `protobuf:"bytes,1,opt,name=agent,proto3" json:"agent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ListSessionsRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[13] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[13] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{13} +} + +func (x *ListSessionsRequest) GetAgent() string { + if x != nil { + return x.Agent + } + return "" +} + +type ListSessionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sessions []*Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsResponse) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ListSessionsResponse{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[14] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResponse) ProtoMessage() {} + +func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[14] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ListSessionsResponse) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{14} +} + +func (x *ListSessionsResponse) GetSessions() []*Session { + if x != nil { + return x.Sessions + } + return nil +} + +type CreateSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Agent string `protobuf:"bytes,1,opt,name=agent,proto3" json:"agent,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateSessionRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = CreateSessionRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[15] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateSessionRequest) ProtoMessage() {} + +func (x *CreateSessionRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[15] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*CreateSessionRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{15} +} + +func (x *CreateSessionRequest) GetAgent() string { + if x != nil { + return x.Agent + } + return "" +} + +func (x *CreateSessionRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type GetSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetSessionRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = GetSessionRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[16] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetSessionRequest) ProtoMessage() {} + +func (x *GetSessionRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[16] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*GetSessionRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{16} +} + +func (x *GetSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type SessionDetail struct { + state protoimpl.MessageState `protogen:"open.v1"` + Session *Session `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"` + Agent *Agent `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"` + Messages []*Message `protobuf:"bytes,3,rep,name=messages,proto3" json:"messages,omitempty"` + ContextTokens int64 `protobuf:"varint,4,opt,name=context_tokens,json=contextTokens,proto3" json:"context_tokens,omitempty"` + ContextWindow int64 `protobuf:"varint,5,opt,name=context_window,json=contextWindow,proto3" json:"context_window,omitempty"` + ContextKnown bool `protobuf:"varint,6,opt,name=context_known,json=contextKnown,proto3" json:"context_known,omitempty"` + ToolCalls []*ToolCall `protobuf:"bytes,7,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionDetail) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = SessionDetail{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[17] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionDetail) ProtoMessage() {} + +func (x *SessionDetail) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[17] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*SessionDetail) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{17} +} + +func (x *SessionDetail) GetSession() *Session { + if x != nil { + return x.Session + } + return nil +} + +func (x *SessionDetail) GetAgent() *Agent { + if x != nil { + return x.Agent + } + return nil +} + +func (x *SessionDetail) GetMessages() []*Message { + if x != nil { + return x.Messages + } + return nil +} + +func (x *SessionDetail) GetContextTokens() int64 { + if x != nil { + return x.ContextTokens + } + return 0 +} + +func (x *SessionDetail) GetContextWindow() int64 { + if x != nil { + return x.ContextWindow + } + return 0 +} + +func (x *SessionDetail) GetContextKnown() bool { + if x != nil { + return x.ContextKnown + } + return false +} + +func (x *SessionDetail) GetToolCalls() []*ToolCall { + if x != nil { + return x.ToolCalls + } + return nil +} + +type RenameSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenameSessionRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = RenameSessionRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[18] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenameSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenameSessionRequest) ProtoMessage() {} + +func (x *RenameSessionRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[18] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*RenameSessionRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{18} +} + +func (x *RenameSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *RenameSessionRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type DeleteSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSessionRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = DeleteSessionRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[19] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSessionRequest) ProtoMessage() {} + +func (x *DeleteSessionRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[19] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*DeleteSessionRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{19} +} + +func (x *DeleteSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type GetUsageRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetUsageRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = GetUsageRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[20] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetUsageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetUsageRequest) ProtoMessage() {} + +func (x *GetUsageRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[20] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*GetUsageRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{20} +} + +func (x *GetUsageRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type CompactSessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompactSessionRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = CompactSessionRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[21] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompactSessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompactSessionRequest) ProtoMessage() {} + +func (x *CompactSessionRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[21] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*CompactSessionRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{21} +} + +func (x *CompactSessionRequest) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +type CompactSessionResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Compacted bool `protobuf:"varint,1,opt,name=compacted,proto3" json:"compacted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompactSessionResponse) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = CompactSessionResponse{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[22] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompactSessionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompactSessionResponse) ProtoMessage() {} + +func (x *CompactSessionResponse) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[22] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*CompactSessionResponse) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{22} +} + +func (x *CompactSessionResponse) GetCompacted() bool { + if x != nil { + return x.Compacted + } + return false +} + +type ChatStart struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + Thinking string `protobuf:"bytes,3,opt,name=thinking,proto3" json:"thinking,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatStart) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatStart{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[23] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatStart) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatStart) ProtoMessage() {} + +func (x *ChatStart) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[23] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatStart) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{23} +} + +func (x *ChatStart) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *ChatStart) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *ChatStart) GetThinking() string { + if x != nil { + return x.Thinking + } + return "" +} + +type ApprovalDecision struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + Choice ApprovalChoice `protobuf:"varint,2,opt,name=choice,proto3,enum=mininaru.v1.ApprovalChoice" json:"choice,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApprovalDecision) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ApprovalDecision{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[24] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApprovalDecision) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApprovalDecision) ProtoMessage() {} + +func (x *ApprovalDecision) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[24] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ApprovalDecision) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{24} +} + +func (x *ApprovalDecision) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ApprovalDecision) GetChoice() ApprovalChoice { + if x != nil { + return x.Choice + } + return ApprovalChoice_APPROVAL_CHOICE_UNSPECIFIED +} + +type ChatClientEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event isChatClientEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatClientEvent) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatClientEvent{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[25] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatClientEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatClientEvent) ProtoMessage() {} + +func (x *ChatClientEvent) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[25] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatClientEvent) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{25} +} + +func (x *ChatClientEvent) GetEvent() isChatClientEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ChatClientEvent) GetStart() *ChatStart { + var ( + xValue *ChatClientEvent_Start + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatClientEvent_Start); ok { + return xValue.Start + } + } + return nil +} + +func (x *ChatClientEvent) GetApproval() *ApprovalDecision { + var ( + xValue *ChatClientEvent_Approval + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatClientEvent_Approval); ok { + return xValue.Approval + } + } + return nil +} + +func (x *ChatClientEvent) GetCancel() *Empty { + var ( + xValue *ChatClientEvent_Cancel + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatClientEvent_Cancel); ok { + return xValue.Cancel + } + } + return nil +} + +type isChatClientEvent_Event interface { + isChatClientEvent_Event() +} + +type ChatClientEvent_Start struct { + Start *ChatStart `protobuf:"bytes,1,opt,name=start,proto3,oneof"` +} + +type ChatClientEvent_Approval struct { + Approval *ApprovalDecision `protobuf:"bytes,2,opt,name=approval,proto3,oneof"` +} + +type ChatClientEvent_Cancel struct { + Cancel *Empty `protobuf:"bytes,3,opt,name=cancel,proto3,oneof"` +} + +func (*ChatClientEvent_Start) isChatClientEvent_Event() {} + +func (*ChatClientEvent_Approval) isChatClientEvent_Event() {} + +func (*ChatClientEvent_Cancel) isChatClientEvent_Event() {} + +type ChatStarted struct { + state protoimpl.MessageState `protogen:"open.v1"` + TurnId string `protobuf:"bytes,1,opt,name=turn_id,json=turnId,proto3" json:"turn_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatStarted) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatStarted{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[26] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatStarted) ProtoMessage() {} + +func (x *ChatStarted) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[26] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatStarted) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{26} +} + +func (x *ChatStarted) GetTurnId() string { + if x != nil { + return x.TurnId + } + return "" +} + +type TextDelta struct { + state protoimpl.MessageState `protogen:"open.v1"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TextDelta) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = TextDelta{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[27] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TextDelta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TextDelta) ProtoMessage() {} + +func (x *TextDelta) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[27] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*TextDelta) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{27} +} + +func (x *TextDelta) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +type ToolEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase string `protobuf:"bytes,1,opt,name=phase,proto3" json:"phase,omitempty"` + CallId string `protobuf:"bytes,2,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Arguments string `protobuf:"bytes,4,opt,name=arguments,proto3" json:"arguments,omitempty"` + Result string `protobuf:"bytes,5,opt,name=result,proto3" json:"result,omitempty"` + Status string `protobuf:"bytes,6,opt,name=status,proto3" json:"status,omitempty"` + Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ToolEvent) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ToolEvent{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[28] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ToolEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ToolEvent) ProtoMessage() {} + +func (x *ToolEvent) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[28] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ToolEvent) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{28} +} + +func (x *ToolEvent) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *ToolEvent) GetCallId() string { + if x != nil { + return x.CallId + } + return "" +} + +func (x *ToolEvent) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ToolEvent) GetArguments() string { + if x != nil { + return x.Arguments + } + return "" +} + +func (x *ToolEvent) GetResult() string { + if x != nil { + return x.Result + } + return "" +} + +func (x *ToolEvent) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *ToolEvent) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ApprovalRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` + ToolName string `protobuf:"bytes,2,opt,name=tool_name,json=toolName,proto3" json:"tool_name,omitempty"` + Arguments string `protobuf:"bytes,3,opt,name=arguments,proto3" json:"arguments,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ApprovalRequest) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ApprovalRequest{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[29] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ApprovalRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ApprovalRequest) ProtoMessage() {} + +func (x *ApprovalRequest) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[29] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ApprovalRequest) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{29} +} + +func (x *ApprovalRequest) GetRequestId() string { + if x != nil { + return x.RequestId + } + return "" +} + +func (x *ApprovalRequest) GetToolName() string { + if x != nil { + return x.ToolName + } + return "" +} + +func (x *ApprovalRequest) GetArguments() string { + if x != nil { + return x.Arguments + } + return "" +} + +type ChatCompleted struct { + state protoimpl.MessageState `protogen:"open.v1"` + Message *Message `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"` + Usage *Usage `protobuf:"bytes,2,opt,name=usage,proto3" json:"usage,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatCompleted) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatCompleted{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[30] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatCompleted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatCompleted) ProtoMessage() {} + +func (x *ChatCompleted) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[30] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatCompleted) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{30} +} + +func (x *ChatCompleted) GetMessage() *Message { + if x != nil { + return x.Message + } + return nil +} + +func (x *ChatCompleted) GetUsage() *Usage { + if x != nil { + return x.Usage + } + return nil +} + +type ChatFailed struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatFailed) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatFailed{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[31] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatFailed) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatFailed) ProtoMessage() {} + +func (x *ChatFailed) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[31] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatFailed) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{31} +} + +func (x *ChatFailed) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *ChatFailed) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type ChatServerEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + Event isChatServerEvent_Event `protobuf_oneof:"event"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ChatServerEvent) Reset() { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + *x = ChatServerEvent{} + mi = &file_mininaru_v1_mininaru_proto_msgTypes[32] + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ChatServerEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ChatServerEvent) ProtoMessage() {} + +func (x *ChatServerEvent) ProtoReflect() protoreflect.Message { + var ( + mi *protoimpl.MessageInfo + ms messageState + ) + + mi = &file_mininaru_v1_mininaru_proto_msgTypes[32] + if x != nil { + ms = protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +func (*ChatServerEvent) Descriptor() ([]byte, []int) { + return file_mininaru_v1_mininaru_proto_rawDescGZIP(), []int{32} +} + +func (x *ChatServerEvent) GetEvent() isChatServerEvent_Event { + if x != nil { + return x.Event + } + return nil +} + +func (x *ChatServerEvent) GetStarted() *ChatStarted { + var ( + xValue *ChatServerEvent_Started + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Started); ok { + return xValue.Started + } + } + return nil +} + +func (x *ChatServerEvent) GetContent() *TextDelta { + var ( + xValue *ChatServerEvent_Content + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Content); ok { + return xValue.Content + } + } + return nil +} + +func (x *ChatServerEvent) GetReasoning() *TextDelta { + var ( + xValue *ChatServerEvent_Reasoning + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Reasoning); ok { + return xValue.Reasoning + } + } + return nil +} + +func (x *ChatServerEvent) GetTool() *ToolEvent { + var ( + xValue *ChatServerEvent_Tool + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Tool); ok { + return xValue.Tool + } + } + return nil +} + +func (x *ChatServerEvent) GetApproval() *ApprovalRequest { + var ( + xValue *ChatServerEvent_Approval + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Approval); ok { + return xValue.Approval + } + } + return nil +} + +func (x *ChatServerEvent) GetCompleted() *ChatCompleted { + var ( + xValue *ChatServerEvent_Completed + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Completed); ok { + return xValue.Completed + } + } + return nil +} + +func (x *ChatServerEvent) GetFailed() *ChatFailed { + var ( + xValue *ChatServerEvent_Failed + ok bool + ) + + if x != nil { + if xValue, ok = x.Event.(*ChatServerEvent_Failed); ok { + return xValue.Failed + } + } + return nil +} + +type isChatServerEvent_Event interface { + isChatServerEvent_Event() +} + +type ChatServerEvent_Started struct { + Started *ChatStarted `protobuf:"bytes,1,opt,name=started,proto3,oneof"` +} + +type ChatServerEvent_Content struct { + Content *TextDelta `protobuf:"bytes,2,opt,name=content,proto3,oneof"` +} + +type ChatServerEvent_Reasoning struct { + Reasoning *TextDelta `protobuf:"bytes,3,opt,name=reasoning,proto3,oneof"` +} + +type ChatServerEvent_Tool struct { + Tool *ToolEvent `protobuf:"bytes,4,opt,name=tool,proto3,oneof"` +} + +type ChatServerEvent_Approval struct { + Approval *ApprovalRequest `protobuf:"bytes,5,opt,name=approval,proto3,oneof"` +} + +type ChatServerEvent_Completed struct { + Completed *ChatCompleted `protobuf:"bytes,6,opt,name=completed,proto3,oneof"` +} + +type ChatServerEvent_Failed struct { + Failed *ChatFailed `protobuf:"bytes,7,opt,name=failed,proto3,oneof"` +} + +func (*ChatServerEvent_Started) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Content) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Reasoning) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Tool) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Approval) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Completed) isChatServerEvent_Event() {} + +func (*ChatServerEvent_Failed) isChatServerEvent_Event() {} + +var File_mininaru_v1_mininaru_proto protoreflect.FileDescriptor + +const file_mininaru_v1_mininaru_proto_rawDesc = "" + + "\n" + + "\x1amininaru/v1/mininaru.proto\x12\vmininaru.v1\"\a\n" + + "\x05Empty\"U\n" + + "\x13BeginPairingRequest\x12\x1d\n" + + "\n" + + "public_key\x18\x01 \x01(\fR\tpublicKey\x12\x1f\n" + + "\vdevice_name\x18\x02 \x01(\tR\n" + + "deviceName\"\xaf\x01\n" + + "\x14BeginPairingResponse\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12!\n" + + "\fpairing_code\x18\x02 \x01(\tR\vpairingCode\x12-\n" + + "\x12client_fingerprint\x18\x03 \x01(\tR\x11clientFingerprint\x12&\n" + + "\x0fexpires_at_unix\x18\x04 \x01(\x03R\rexpiresAtUnix\"4\n" + + "\x13WatchPairingRequest\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\"\xb9\x01\n" + + "\fPairingEvent\x12/\n" + + "\x05state\x18\x01 \x01(\x0e2\x19.mininaru.v1.PairingStateR\x05state\x124\n" + + "\x16client_certificate_pem\x18\x02 \x01(\fR\x14clientCertificatePem\x12,\n" + + "\x12ca_certificate_pem\x18\x03 \x01(\fR\x10caCertificatePem\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"]\n" + + "\x05Agent\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" + + "\x05model\x18\x03 \x01(\tR\x05model\x12\x1a\n" + + "\bprovider\x18\x04 \x01(\tR\bprovider\"H\n" + + "\aSession\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\"\xb2\x01\n" + + "\aMessage\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\x12\x18\n" + + "\acontent\x18\x04 \x01(\tR\acontent\x12\x1c\n" + + "\treasoning\x18\x05 \x01(\tR\treasoning\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12\x14\n" + + "\x05error\x18\a \x01(\tR\x05error\"\xca\x01\n" + + "\bToolCall\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\acall_id\x18\x02 \x01(\tR\x06callId\x12\x1d\n" + + "\n" + + "message_id\x18\x03 \x01(\tR\tmessageId\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12\x1c\n" + + "\targuments\x18\x05 \x01(\tR\targuments\x12\x16\n" + + "\x06result\x18\x06 \x01(\tR\x06result\x12\x16\n" + + "\x06status\x18\a \x01(\tR\x06status\x12\x14\n" + + "\x05error\x18\b \x01(\tR\x05error\"\xe7\x01\n" + + "\tUsageLine\x12\x12\n" + + "\x04kind\x18\x01 \x01(\tR\x04kind\x12#\n" + + "\rprompt_tokens\x18\x02 \x01(\x03R\fpromptTokens\x12+\n" + + "\x11completion_tokens\x18\x03 \x01(\x03R\x10completionTokens\x12!\n" + + "\ftotal_tokens\x18\x04 \x01(\x03R\vtotalTokens\x12#\n" + + "\rcached_tokens\x18\x05 \x01(\x03R\fcachedTokens\x12,\n" + + "\x12cache_write_tokens\x18\x06 \x01(\x03R\x10cacheWriteTokens\"\x9c\x02\n" + + "\x05Usage\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12,\n" + + "\x05lines\x18\x02 \x03(\v2\x16.mininaru.v1.UsageLineR\x05lines\x12#\n" + + "\rprompt_tokens\x18\x03 \x01(\x03R\fpromptTokens\x12+\n" + + "\x11completion_tokens\x18\x04 \x01(\x03R\x10completionTokens\x12!\n" + + "\ftotal_tokens\x18\x05 \x01(\x03R\vtotalTokens\x12#\n" + + "\rcached_tokens\x18\x06 \x01(\x03R\fcachedTokens\x12,\n" + + "\x12cache_write_tokens\x18\a \x01(\x03R\x10cacheWriteTokens\"\x13\n" + + "\x11ListAgentsRequest\"j\n" + + "\x12ListAgentsResponse\x12*\n" + + "\x06agents\x18\x01 \x03(\v2\x12.mininaru.v1.AgentR\x06agents\x12(\n" + + "\x10default_agent_id\x18\x02 \x01(\tR\x0edefaultAgentId\"+\n" + + "\x13ListSessionsRequest\x12\x14\n" + + "\x05agent\x18\x01 \x01(\tR\x05agent\"H\n" + + "\x14ListSessionsResponse\x120\n" + + "\bsessions\x18\x01 \x03(\v2\x14.mininaru.v1.SessionR\bsessions\"@\n" + + "\x14CreateSessionRequest\x12\x14\n" + + "\x05agent\x18\x01 \x01(\tR\x05agent\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"2\n" + + "\x11GetSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xc4\x02\n" + + "\rSessionDetail\x12.\n" + + "\asession\x18\x01 \x01(\v2\x14.mininaru.v1.SessionR\asession\x12(\n" + + "\x05agent\x18\x02 \x01(\v2\x12.mininaru.v1.AgentR\x05agent\x120\n" + + "\bmessages\x18\x03 \x03(\v2\x14.mininaru.v1.MessageR\bmessages\x12%\n" + + "\x0econtext_tokens\x18\x04 \x01(\x03R\rcontextTokens\x12%\n" + + "\x0econtext_window\x18\x05 \x01(\x03R\rcontextWindow\x12#\n" + + "\rcontext_known\x18\x06 \x01(\bR\fcontextKnown\x124\n" + + "\n" + + "tool_calls\x18\a \x03(\v2\x15.mininaru.v1.ToolCallR\ttoolCalls\"I\n" + + "\x14RenameSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\"5\n" + + "\x14DeleteSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"0\n" + + "\x0fGetUsageRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"6\n" + + "\x15CompactSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"6\n" + + "\x16CompactSessionResponse\x12\x1c\n" + + "\tcompacted\x18\x01 \x01(\bR\tcompacted\"`\n" + + "\tChatStart\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\x12\x1a\n" + + "\bthinking\x18\x03 \x01(\tR\bthinking\"f\n" + + "\x10ApprovalDecision\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x123\n" + + "\x06choice\x18\x02 \x01(\x0e2\x1b.mininaru.v1.ApprovalChoiceR\x06choice\"\xb5\x01\n" + + "\x0fChatClientEvent\x12.\n" + + "\x05start\x18\x01 \x01(\v2\x16.mininaru.v1.ChatStartH\x00R\x05start\x12;\n" + + "\bapproval\x18\x02 \x01(\v2\x1d.mininaru.v1.ApprovalDecisionH\x00R\bapproval\x12,\n" + + "\x06cancel\x18\x03 \x01(\v2\x12.mininaru.v1.EmptyH\x00R\x06cancelB\a\n" + + "\x05event\"&\n" + + "\vChatStarted\x12\x17\n" + + "\aturn_id\x18\x01 \x01(\tR\x06turnId\"\x1f\n" + + "\tTextDelta\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\"\xb2\x01\n" + + "\tToolEvent\x12\x14\n" + + "\x05phase\x18\x01 \x01(\tR\x05phase\x12\x17\n" + + "\acall_id\x18\x02 \x01(\tR\x06callId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n" + + "\targuments\x18\x04 \x01(\tR\targuments\x12\x16\n" + + "\x06result\x18\x05 \x01(\tR\x06result\x12\x16\n" + + "\x06status\x18\x06 \x01(\tR\x06status\x12\x14\n" + + "\x05error\x18\a \x01(\tR\x05error\"k\n" + + "\x0fApprovalRequest\x12\x1d\n" + + "\n" + + "request_id\x18\x01 \x01(\tR\trequestId\x12\x1b\n" + + "\ttool_name\x18\x02 \x01(\tR\btoolName\x12\x1c\n" + + "\targuments\x18\x03 \x01(\tR\targuments\"i\n" + + "\rChatCompleted\x12.\n" + + "\amessage\x18\x01 \x01(\v2\x14.mininaru.v1.MessageR\amessage\x12(\n" + + "\x05usage\x18\x02 \x01(\v2\x12.mininaru.v1.UsageR\x05usage\":\n" + + "\n" + + "ChatFailed\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"\x95\x03\n" + + "\x0fChatServerEvent\x124\n" + + "\astarted\x18\x01 \x01(\v2\x18.mininaru.v1.ChatStartedH\x00R\astarted\x122\n" + + "\acontent\x18\x02 \x01(\v2\x16.mininaru.v1.TextDeltaH\x00R\acontent\x126\n" + + "\treasoning\x18\x03 \x01(\v2\x16.mininaru.v1.TextDeltaH\x00R\treasoning\x12,\n" + + "\x04tool\x18\x04 \x01(\v2\x16.mininaru.v1.ToolEventH\x00R\x04tool\x12:\n" + + "\bapproval\x18\x05 \x01(\v2\x1c.mininaru.v1.ApprovalRequestH\x00R\bapproval\x12:\n" + + "\tcompleted\x18\x06 \x01(\v2\x1a.mininaru.v1.ChatCompletedH\x00R\tcompleted\x121\n" + + "\x06failed\x18\a \x01(\v2\x17.mininaru.v1.ChatFailedH\x00R\x06failedB\a\n" + + "\x05event*\x99\x01\n" + + "\fPairingState\x12\x1d\n" + + "\x19PAIRING_STATE_UNSPECIFIED\x10\x00\x12\x19\n" + + "\x15PAIRING_STATE_WAITING\x10\x01\x12\x1a\n" + + "\x16PAIRING_STATE_APPROVED\x10\x02\x12\x18\n" + + "\x14PAIRING_STATE_DENIED\x10\x03\x12\x19\n" + + "\x15PAIRING_STATE_EXPIRED\x10\x04*\x82\x01\n" + + "\x0eApprovalChoice\x12\x1f\n" + + "\x1bAPPROVAL_CHOICE_UNSPECIFIED\x10\x00\x12\x18\n" + + "\x14APPROVAL_CHOICE_DENY\x10\x01\x12\x18\n" + + "\x14APPROVAL_CHOICE_ONCE\x10\x02\x12\x1b\n" + + "\x17APPROVAL_CHOICE_SESSION\x10\x032\xa6\x01\n" + + "\x0ePairingService\x12L\n" + + "\x05Begin\x12 .mininaru.v1.BeginPairingRequest\x1a!.mininaru.v1.BeginPairingResponse\x12F\n" + + "\x05Watch\x12 .mininaru.v1.WatchPairingRequest\x1a\x19.mininaru.v1.PairingEvent0\x012\xbc\x05\n" + + "\x0fMininaruService\x12M\n" + + "\n" + + "ListAgents\x12\x1e.mininaru.v1.ListAgentsRequest\x1a\x1f.mininaru.v1.ListAgentsResponse\x12S\n" + + "\fListSessions\x12 .mininaru.v1.ListSessionsRequest\x1a!.mininaru.v1.ListSessionsResponse\x12H\n" + + "\rCreateSession\x12!.mininaru.v1.CreateSessionRequest\x1a\x14.mininaru.v1.Session\x12H\n" + + "\n" + + "GetSession\x12\x1e.mininaru.v1.GetSessionRequest\x1a\x1a.mininaru.v1.SessionDetail\x12H\n" + + "\rRenameSession\x12!.mininaru.v1.RenameSessionRequest\x1a\x14.mininaru.v1.Session\x12F\n" + + "\rDeleteSession\x12!.mininaru.v1.DeleteSessionRequest\x1a\x12.mininaru.v1.Empty\x12<\n" + + "\bGetUsage\x12\x1c.mininaru.v1.GetUsageRequest\x1a\x12.mininaru.v1.Usage\x12Y\n" + + "\x0eCompactSession\x12\".mininaru.v1.CompactSessionRequest\x1a#.mininaru.v1.CompactSessionResponse\x12F\n" + + "\x04Chat\x12\x1c.mininaru.v1.ChatClientEvent\x1a\x1c.mininaru.v1.ChatServerEvent(\x010\x01B=Z;github.com/devproje/mininaru/rpc/gen/mininaru/v1;mininaruv1b\x06proto3" + +var ( + file_mininaru_v1_mininaru_proto_rawDescOnce sync.Once + file_mininaru_v1_mininaru_proto_rawDescData []byte +) + +func file_mininaru_v1_mininaru_proto_rawDescGZIP() []byte { + file_mininaru_v1_mininaru_proto_rawDescOnce.Do(func() { + file_mininaru_v1_mininaru_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_mininaru_v1_mininaru_proto_rawDesc), len(file_mininaru_v1_mininaru_proto_rawDesc))) + }) + return file_mininaru_v1_mininaru_proto_rawDescData +} + +var file_mininaru_v1_mininaru_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_mininaru_v1_mininaru_proto_msgTypes = make([]protoimpl.MessageInfo, 33) +var file_mininaru_v1_mininaru_proto_goTypes = []any{ + (PairingState)(0), // 0: mininaru.v1.PairingState + (ApprovalChoice)(0), // 1: mininaru.v1.ApprovalChoice + (*Empty)(nil), // 2: mininaru.v1.Empty + (*BeginPairingRequest)(nil), // 3: mininaru.v1.BeginPairingRequest + (*BeginPairingResponse)(nil), // 4: mininaru.v1.BeginPairingResponse + (*WatchPairingRequest)(nil), // 5: mininaru.v1.WatchPairingRequest + (*PairingEvent)(nil), // 6: mininaru.v1.PairingEvent + (*Agent)(nil), // 7: mininaru.v1.Agent + (*Session)(nil), // 8: mininaru.v1.Session + (*Message)(nil), // 9: mininaru.v1.Message + (*ToolCall)(nil), // 10: mininaru.v1.ToolCall + (*UsageLine)(nil), // 11: mininaru.v1.UsageLine + (*Usage)(nil), // 12: mininaru.v1.Usage + (*ListAgentsRequest)(nil), // 13: mininaru.v1.ListAgentsRequest + (*ListAgentsResponse)(nil), // 14: mininaru.v1.ListAgentsResponse + (*ListSessionsRequest)(nil), // 15: mininaru.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 16: mininaru.v1.ListSessionsResponse + (*CreateSessionRequest)(nil), // 17: mininaru.v1.CreateSessionRequest + (*GetSessionRequest)(nil), // 18: mininaru.v1.GetSessionRequest + (*SessionDetail)(nil), // 19: mininaru.v1.SessionDetail + (*RenameSessionRequest)(nil), // 20: mininaru.v1.RenameSessionRequest + (*DeleteSessionRequest)(nil), // 21: mininaru.v1.DeleteSessionRequest + (*GetUsageRequest)(nil), // 22: mininaru.v1.GetUsageRequest + (*CompactSessionRequest)(nil), // 23: mininaru.v1.CompactSessionRequest + (*CompactSessionResponse)(nil), // 24: mininaru.v1.CompactSessionResponse + (*ChatStart)(nil), // 25: mininaru.v1.ChatStart + (*ApprovalDecision)(nil), // 26: mininaru.v1.ApprovalDecision + (*ChatClientEvent)(nil), // 27: mininaru.v1.ChatClientEvent + (*ChatStarted)(nil), // 28: mininaru.v1.ChatStarted + (*TextDelta)(nil), // 29: mininaru.v1.TextDelta + (*ToolEvent)(nil), // 30: mininaru.v1.ToolEvent + (*ApprovalRequest)(nil), // 31: mininaru.v1.ApprovalRequest + (*ChatCompleted)(nil), // 32: mininaru.v1.ChatCompleted + (*ChatFailed)(nil), // 33: mininaru.v1.ChatFailed + (*ChatServerEvent)(nil), // 34: mininaru.v1.ChatServerEvent +} +var file_mininaru_v1_mininaru_proto_depIdxs = []int32{ + 0, // 0: mininaru.v1.PairingEvent.state:type_name -> mininaru.v1.PairingState + 11, // 1: mininaru.v1.Usage.lines:type_name -> mininaru.v1.UsageLine + 7, // 2: mininaru.v1.ListAgentsResponse.agents:type_name -> mininaru.v1.Agent + 8, // 3: mininaru.v1.ListSessionsResponse.sessions:type_name -> mininaru.v1.Session + 8, // 4: mininaru.v1.SessionDetail.session:type_name -> mininaru.v1.Session + 7, // 5: mininaru.v1.SessionDetail.agent:type_name -> mininaru.v1.Agent + 9, // 6: mininaru.v1.SessionDetail.messages:type_name -> mininaru.v1.Message + 10, // 7: mininaru.v1.SessionDetail.tool_calls:type_name -> mininaru.v1.ToolCall + 1, // 8: mininaru.v1.ApprovalDecision.choice:type_name -> mininaru.v1.ApprovalChoice + 25, // 9: mininaru.v1.ChatClientEvent.start:type_name -> mininaru.v1.ChatStart + 26, // 10: mininaru.v1.ChatClientEvent.approval:type_name -> mininaru.v1.ApprovalDecision + 2, // 11: mininaru.v1.ChatClientEvent.cancel:type_name -> mininaru.v1.Empty + 9, // 12: mininaru.v1.ChatCompleted.message:type_name -> mininaru.v1.Message + 12, // 13: mininaru.v1.ChatCompleted.usage:type_name -> mininaru.v1.Usage + 28, // 14: mininaru.v1.ChatServerEvent.started:type_name -> mininaru.v1.ChatStarted + 29, // 15: mininaru.v1.ChatServerEvent.content:type_name -> mininaru.v1.TextDelta + 29, // 16: mininaru.v1.ChatServerEvent.reasoning:type_name -> mininaru.v1.TextDelta + 30, // 17: mininaru.v1.ChatServerEvent.tool:type_name -> mininaru.v1.ToolEvent + 31, // 18: mininaru.v1.ChatServerEvent.approval:type_name -> mininaru.v1.ApprovalRequest + 32, // 19: mininaru.v1.ChatServerEvent.completed:type_name -> mininaru.v1.ChatCompleted + 33, // 20: mininaru.v1.ChatServerEvent.failed:type_name -> mininaru.v1.ChatFailed + 3, // 21: mininaru.v1.PairingService.Begin:input_type -> mininaru.v1.BeginPairingRequest + 5, // 22: mininaru.v1.PairingService.Watch:input_type -> mininaru.v1.WatchPairingRequest + 13, // 23: mininaru.v1.MininaruService.ListAgents:input_type -> mininaru.v1.ListAgentsRequest + 15, // 24: mininaru.v1.MininaruService.ListSessions:input_type -> mininaru.v1.ListSessionsRequest + 17, // 25: mininaru.v1.MininaruService.CreateSession:input_type -> mininaru.v1.CreateSessionRequest + 18, // 26: mininaru.v1.MininaruService.GetSession:input_type -> mininaru.v1.GetSessionRequest + 20, // 27: mininaru.v1.MininaruService.RenameSession:input_type -> mininaru.v1.RenameSessionRequest + 21, // 28: mininaru.v1.MininaruService.DeleteSession:input_type -> mininaru.v1.DeleteSessionRequest + 22, // 29: mininaru.v1.MininaruService.GetUsage:input_type -> mininaru.v1.GetUsageRequest + 23, // 30: mininaru.v1.MininaruService.CompactSession:input_type -> mininaru.v1.CompactSessionRequest + 27, // 31: mininaru.v1.MininaruService.Chat:input_type -> mininaru.v1.ChatClientEvent + 4, // 32: mininaru.v1.PairingService.Begin:output_type -> mininaru.v1.BeginPairingResponse + 6, // 33: mininaru.v1.PairingService.Watch:output_type -> mininaru.v1.PairingEvent + 14, // 34: mininaru.v1.MininaruService.ListAgents:output_type -> mininaru.v1.ListAgentsResponse + 16, // 35: mininaru.v1.MininaruService.ListSessions:output_type -> mininaru.v1.ListSessionsResponse + 8, // 36: mininaru.v1.MininaruService.CreateSession:output_type -> mininaru.v1.Session + 19, // 37: mininaru.v1.MininaruService.GetSession:output_type -> mininaru.v1.SessionDetail + 8, // 38: mininaru.v1.MininaruService.RenameSession:output_type -> mininaru.v1.Session + 2, // 39: mininaru.v1.MininaruService.DeleteSession:output_type -> mininaru.v1.Empty + 12, // 40: mininaru.v1.MininaruService.GetUsage:output_type -> mininaru.v1.Usage + 24, // 41: mininaru.v1.MininaruService.CompactSession:output_type -> mininaru.v1.CompactSessionResponse + 34, // 42: mininaru.v1.MininaruService.Chat:output_type -> mininaru.v1.ChatServerEvent + 32, // [32:43] is the sub-list for method output_type + 21, // [21:32] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_mininaru_v1_mininaru_proto_init() } +func file_mininaru_v1_mininaru_proto_init() { + var () + if File_mininaru_v1_mininaru_proto != nil { + return + } + file_mininaru_v1_mininaru_proto_msgTypes[25].OneofWrappers = []any{ + (*ChatClientEvent_Start)(nil), + (*ChatClientEvent_Approval)(nil), + (*ChatClientEvent_Cancel)(nil), + } + file_mininaru_v1_mininaru_proto_msgTypes[32].OneofWrappers = []any{ + (*ChatServerEvent_Started)(nil), + (*ChatServerEvent_Content)(nil), + (*ChatServerEvent_Reasoning)(nil), + (*ChatServerEvent_Tool)(nil), + (*ChatServerEvent_Approval)(nil), + (*ChatServerEvent_Completed)(nil), + (*ChatServerEvent_Failed)(nil), + } + type x struct{} + + File_mininaru_v1_mininaru_proto = protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_mininaru_v1_mininaru_proto_rawDesc), len(file_mininaru_v1_mininaru_proto_rawDesc)), + NumEnums: 2, + NumMessages: 33, + NumExtensions: 0, + NumServices: 2, + }, + GoTypes: file_mininaru_v1_mininaru_proto_goTypes, + DependencyIndexes: file_mininaru_v1_mininaru_proto_depIdxs, + EnumInfos: file_mininaru_v1_mininaru_proto_enumTypes, + MessageInfos: file_mininaru_v1_mininaru_proto_msgTypes, + }.Build().File + + file_mininaru_v1_mininaru_proto_goTypes = nil + file_mininaru_v1_mininaru_proto_depIdxs = nil +} diff --git a/rpc/gen/mininaru/v1/mininaru_grpc.pb.go b/rpc/gen/mininaru/v1/mininaru_grpc.pb.go new file mode 100644 index 0000000..206eb16 --- /dev/null +++ b/rpc/gen/mininaru/v1/mininaru_grpc.pb.go @@ -0,0 +1,658 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package mininaruv1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +const _ = grpc.SupportPackageIsVersion9 + +const ( + PairingService_Begin_FullMethodName = "/mininaru.v1.PairingService/Begin" + PairingService_Watch_FullMethodName = "/mininaru.v1.PairingService/Watch" +) + +type PairingServiceClient interface { + Begin(ctx context.Context, in *BeginPairingRequest, opts ...grpc.CallOption) (*BeginPairingResponse, error) + Watch(ctx context.Context, in *WatchPairingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PairingEvent], error) +} + +type pairingServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewPairingServiceClient(cc grpc.ClientConnInterface) PairingServiceClient { + return &pairingServiceClient{cc} +} + +func (c *pairingServiceClient) Begin(ctx context.Context, in *BeginPairingRequest, opts ...grpc.CallOption) (*BeginPairingResponse, error) { + var ( + cOpts []grpc.CallOption + out *BeginPairingResponse + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(BeginPairingResponse) + err = c.cc.Invoke(ctx, PairingService_Begin_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *pairingServiceClient) Watch(ctx context.Context, in *WatchPairingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[PairingEvent], error) { + var ( + cOpts []grpc.CallOption + stream grpc.ClientStream + x *grpc.GenericClientStream[WatchPairingRequest, PairingEvent] + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err = c.cc.NewStream(ctx, &PairingService_ServiceDesc.Streams[0], PairingService_Watch_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x = &grpc.GenericClientStream[WatchPairingRequest, PairingEvent]{ClientStream: stream} + if err = x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err = x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type PairingService_WatchClient = grpc.ServerStreamingClient[PairingEvent] + +type PairingServiceServer interface { + Begin(context.Context, *BeginPairingRequest) (*BeginPairingResponse, error) + Watch(*WatchPairingRequest, grpc.ServerStreamingServer[PairingEvent]) error + mustEmbedUnimplementedPairingServiceServer() +} + +type UnimplementedPairingServiceServer struct{} + +func (UnimplementedPairingServiceServer) Begin(context.Context, *BeginPairingRequest) (*BeginPairingResponse, error) { + return nil, status.Error(codes.Unimplemented, "method Begin not implemented") +} +func (UnimplementedPairingServiceServer) Watch(*WatchPairingRequest, grpc.ServerStreamingServer[PairingEvent]) error { + return status.Error(codes.Unimplemented, "method Watch not implemented") +} +func (UnimplementedPairingServiceServer) mustEmbedUnimplementedPairingServiceServer() {} +func (UnimplementedPairingServiceServer) testEmbeddedByValue() {} + +type UnsafePairingServiceServer interface { + mustEmbedUnimplementedPairingServiceServer() +} + +func RegisterPairingServiceServer(s grpc.ServiceRegistrar, srv PairingServiceServer) { + var ( + t interface{ testEmbeddedByValue() } + ok bool + ) + + if t, ok = srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&PairingService_ServiceDesc, srv) +} + +func _PairingService_Begin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *BeginPairingRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(BeginPairingRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(PairingServiceServer).Begin(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: PairingService_Begin_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(PairingServiceServer).Begin(ctx, req.(*BeginPairingRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _PairingService_Watch_Handler(srv interface{}, stream grpc.ServerStream) error { + var ( + m *WatchPairingRequest + err error + ) + + m = new(WatchPairingRequest) + if err = stream.RecvMsg(m); err != nil { + return err + } + return srv.(PairingServiceServer).Watch(m, &grpc.GenericServerStream[WatchPairingRequest, PairingEvent]{ServerStream: stream}) +} + +type PairingService_WatchServer = grpc.ServerStreamingServer[PairingEvent] + +var PairingService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "mininaru.v1.PairingService", + HandlerType: (*PairingServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Begin", + Handler: _PairingService_Begin_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Watch", + Handler: _PairingService_Watch_Handler, + ServerStreams: true, + }, + }, + Metadata: "mininaru/v1/mininaru.proto", +} + +const ( + MininaruService_ListAgents_FullMethodName = "/mininaru.v1.MininaruService/ListAgents" + MininaruService_ListSessions_FullMethodName = "/mininaru.v1.MininaruService/ListSessions" + MininaruService_CreateSession_FullMethodName = "/mininaru.v1.MininaruService/CreateSession" + MininaruService_GetSession_FullMethodName = "/mininaru.v1.MininaruService/GetSession" + MininaruService_RenameSession_FullMethodName = "/mininaru.v1.MininaruService/RenameSession" + MininaruService_DeleteSession_FullMethodName = "/mininaru.v1.MininaruService/DeleteSession" + MininaruService_GetUsage_FullMethodName = "/mininaru.v1.MininaruService/GetUsage" + MininaruService_CompactSession_FullMethodName = "/mininaru.v1.MininaruService/CompactSession" + MininaruService_Chat_FullMethodName = "/mininaru.v1.MininaruService/Chat" +) + +type MininaruServiceClient interface { + ListAgents(ctx context.Context, in *ListAgentsRequest, opts ...grpc.CallOption) (*ListAgentsResponse, error) + ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) + CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*Session, error) + GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*SessionDetail, error) + RenameSession(ctx context.Context, in *RenameSessionRequest, opts ...grpc.CallOption) (*Session, error) + DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*Empty, error) + GetUsage(ctx context.Context, in *GetUsageRequest, opts ...grpc.CallOption) (*Usage, error) + CompactSession(ctx context.Context, in *CompactSessionRequest, opts ...grpc.CallOption) (*CompactSessionResponse, error) + Chat(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ChatClientEvent, ChatServerEvent], error) +} + +type mininaruServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewMininaruServiceClient(cc grpc.ClientConnInterface) MininaruServiceClient { + return &mininaruServiceClient{cc} +} + +func (c *mininaruServiceClient) ListAgents(ctx context.Context, in *ListAgentsRequest, opts ...grpc.CallOption) (*ListAgentsResponse, error) { + var ( + cOpts []grpc.CallOption + out *ListAgentsResponse + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(ListAgentsResponse) + err = c.cc.Invoke(ctx, MininaruService_ListAgents_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) { + var ( + cOpts []grpc.CallOption + out *ListSessionsResponse + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(ListSessionsResponse) + err = c.cc.Invoke(ctx, MininaruService_ListSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) CreateSession(ctx context.Context, in *CreateSessionRequest, opts ...grpc.CallOption) (*Session, error) { + var ( + cOpts []grpc.CallOption + out *Session + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(Session) + err = c.cc.Invoke(ctx, MininaruService_CreateSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*SessionDetail, error) { + var ( + cOpts []grpc.CallOption + out *SessionDetail + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(SessionDetail) + err = c.cc.Invoke(ctx, MininaruService_GetSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) RenameSession(ctx context.Context, in *RenameSessionRequest, opts ...grpc.CallOption) (*Session, error) { + var ( + cOpts []grpc.CallOption + out *Session + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(Session) + err = c.cc.Invoke(ctx, MininaruService_RenameSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) DeleteSession(ctx context.Context, in *DeleteSessionRequest, opts ...grpc.CallOption) (*Empty, error) { + var ( + cOpts []grpc.CallOption + out *Empty + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(Empty) + err = c.cc.Invoke(ctx, MininaruService_DeleteSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) GetUsage(ctx context.Context, in *GetUsageRequest, opts ...grpc.CallOption) (*Usage, error) { + var ( + cOpts []grpc.CallOption + out *Usage + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(Usage) + err = c.cc.Invoke(ctx, MininaruService_GetUsage_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) CompactSession(ctx context.Context, in *CompactSessionRequest, opts ...grpc.CallOption) (*CompactSessionResponse, error) { + var ( + cOpts []grpc.CallOption + out *CompactSessionResponse + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out = new(CompactSessionResponse) + err = c.cc.Invoke(ctx, MininaruService_CompactSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *mininaruServiceClient) Chat(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ChatClientEvent, ChatServerEvent], error) { + var ( + cOpts []grpc.CallOption + stream grpc.ClientStream + x *grpc.GenericClientStream[ChatClientEvent, ChatServerEvent] + err error + ) + + cOpts = append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err = c.cc.NewStream(ctx, &MininaruService_ServiceDesc.Streams[0], MininaruService_Chat_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x = &grpc.GenericClientStream[ChatClientEvent, ChatServerEvent]{ClientStream: stream} + return x, nil +} + +type MininaruService_ChatClient = grpc.BidiStreamingClient[ChatClientEvent, ChatServerEvent] + +type MininaruServiceServer interface { + ListAgents(context.Context, *ListAgentsRequest) (*ListAgentsResponse, error) + ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) + CreateSession(context.Context, *CreateSessionRequest) (*Session, error) + GetSession(context.Context, *GetSessionRequest) (*SessionDetail, error) + RenameSession(context.Context, *RenameSessionRequest) (*Session, error) + DeleteSession(context.Context, *DeleteSessionRequest) (*Empty, error) + GetUsage(context.Context, *GetUsageRequest) (*Usage, error) + CompactSession(context.Context, *CompactSessionRequest) (*CompactSessionResponse, error) + Chat(grpc.BidiStreamingServer[ChatClientEvent, ChatServerEvent]) error + mustEmbedUnimplementedMininaruServiceServer() +} + +type UnimplementedMininaruServiceServer struct{} + +func (UnimplementedMininaruServiceServer) ListAgents(context.Context, *ListAgentsRequest) (*ListAgentsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListAgents not implemented") +} +func (UnimplementedMininaruServiceServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedMininaruServiceServer) CreateSession(context.Context, *CreateSessionRequest) (*Session, error) { + return nil, status.Error(codes.Unimplemented, "method CreateSession not implemented") +} +func (UnimplementedMininaruServiceServer) GetSession(context.Context, *GetSessionRequest) (*SessionDetail, error) { + return nil, status.Error(codes.Unimplemented, "method GetSession not implemented") +} +func (UnimplementedMininaruServiceServer) RenameSession(context.Context, *RenameSessionRequest) (*Session, error) { + return nil, status.Error(codes.Unimplemented, "method RenameSession not implemented") +} +func (UnimplementedMininaruServiceServer) DeleteSession(context.Context, *DeleteSessionRequest) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteSession not implemented") +} +func (UnimplementedMininaruServiceServer) GetUsage(context.Context, *GetUsageRequest) (*Usage, error) { + return nil, status.Error(codes.Unimplemented, "method GetUsage not implemented") +} +func (UnimplementedMininaruServiceServer) CompactSession(context.Context, *CompactSessionRequest) (*CompactSessionResponse, error) { + return nil, status.Error(codes.Unimplemented, "method CompactSession not implemented") +} +func (UnimplementedMininaruServiceServer) Chat(grpc.BidiStreamingServer[ChatClientEvent, ChatServerEvent]) error { + return status.Error(codes.Unimplemented, "method Chat not implemented") +} +func (UnimplementedMininaruServiceServer) mustEmbedUnimplementedMininaruServiceServer() {} +func (UnimplementedMininaruServiceServer) testEmbeddedByValue() {} + +type UnsafeMininaruServiceServer interface { + mustEmbedUnimplementedMininaruServiceServer() +} + +func RegisterMininaruServiceServer(s grpc.ServiceRegistrar, srv MininaruServiceServer) { + var ( + t interface{ testEmbeddedByValue() } + ok bool + ) + + if t, ok = srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&MininaruService_ServiceDesc, srv) +} + +func _MininaruService_ListAgents_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *ListAgentsRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(ListAgentsRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).ListAgents(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_ListAgents_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).ListAgents(ctx, req.(*ListAgentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *ListSessionsRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(ListSessionsRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).ListSessions(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_ListSessions_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).ListSessions(ctx, req.(*ListSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_CreateSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *CreateSessionRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(CreateSessionRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).CreateSession(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_CreateSession_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).CreateSession(ctx, req.(*CreateSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *GetSessionRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(GetSessionRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).GetSession(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_GetSession_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).GetSession(ctx, req.(*GetSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_RenameSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *RenameSessionRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(RenameSessionRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).RenameSession(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_RenameSession_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).RenameSession(ctx, req.(*RenameSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_DeleteSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *DeleteSessionRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(DeleteSessionRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).DeleteSession(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_DeleteSession_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).DeleteSession(ctx, req.(*DeleteSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_GetUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *GetUsageRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(GetUsageRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).GetUsage(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_GetUsage_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).GetUsage(ctx, req.(*GetUsageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_CompactSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + var ( + in *CompactSessionRequest + info *grpc.UnaryServerInfo + handler func(ctx context.Context, req interface{}) (interface{}, error) + err error + ) + + in = new(CompactSessionRequest) + if err = dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MininaruServiceServer).CompactSession(ctx, in) + } + info = &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MininaruService_CompactSession_FullMethodName, + } + handler = func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MininaruServiceServer).CompactSession(ctx, req.(*CompactSessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MininaruService_Chat_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(MininaruServiceServer).Chat(&grpc.GenericServerStream[ChatClientEvent, ChatServerEvent]{ServerStream: stream}) +} + +type MininaruService_ChatServer = grpc.BidiStreamingServer[ChatClientEvent, ChatServerEvent] + +var MininaruService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "mininaru.v1.MininaruService", + HandlerType: (*MininaruServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ListAgents", + Handler: _MininaruService_ListAgents_Handler, + }, + { + MethodName: "ListSessions", + Handler: _MininaruService_ListSessions_Handler, + }, + { + MethodName: "CreateSession", + Handler: _MininaruService_CreateSession_Handler, + }, + { + MethodName: "GetSession", + Handler: _MininaruService_GetSession_Handler, + }, + { + MethodName: "RenameSession", + Handler: _MininaruService_RenameSession_Handler, + }, + { + MethodName: "DeleteSession", + Handler: _MininaruService_DeleteSession_Handler, + }, + { + MethodName: "GetUsage", + Handler: _MininaruService_GetUsage_Handler, + }, + { + MethodName: "CompactSession", + Handler: _MininaruService_CompactSession_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "Chat", + Handler: _MininaruService_Chat_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "mininaru/v1/mininaru.proto", +} diff --git a/rpc/gen/mininaru/v1/style_types.go b/rpc/gen/mininaru/v1/style_types.go new file mode 100644 index 0000000..5242308 --- /dev/null +++ b/rpc/gen/mininaru/v1/style_types.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package mininaruv1 + +import ( + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/runtime/protoimpl" +) + +type messageState interface { + protoreflect.Message + + StoreMessageInfo(*protoimpl.MessageInfo) + LoadMessageInfo() *protoimpl.MessageInfo +} diff --git a/rpc/pairing.go b/rpc/pairing.go new file mode 100644 index 0000000..c871d21 --- /dev/null +++ b/rpc/pairing.go @@ -0,0 +1,422 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "database/sql" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "github.com/devproje/mininaru/util" + "github.com/google/uuid" +) + +type ClientDevice struct { + Id string + Name string + Fingerprint string + CertificateSerial string + PairedAt int64 + LastSeenAt int64 + RevokedAt int64 +} + +type PairingRequest struct { + Id string + Code string + Name string + Fingerprint string + PublicKey []byte + CertificatePEM []byte + CreatedAt int64 + ExpiresAt int64 + Status string +} + +const pairingLifetime = 5 * time.Minute + +const pairingRetention = 24 * time.Hour + +const ( + pairingWaiting = "waiting" + pairingApproved = "approved" + pairingDenied = "denied" + pairingExpired = "expired" +) + +const maxDeviceNameBytes = 80 + +const maxPendingPairings = 128 + +func pairingCode() (string, error) { + var upper *big.Int + var value *big.Int + + var err error + + upper = big.NewInt(1000000) + value, err = rand.Int(rand.Reader, upper) + if err != nil { + return "", err + } + + return fmt.Sprintf("%06d", value.Int64()), nil +} + +func pairingPublicKey(encoded []byte) (ed25519.PublicKey, string, error) { + var parsed any + var publicKey ed25519.PublicKey + var fingerprint string + var ok bool + + var err error + + parsed, err = x509.ParsePKIXPublicKey(encoded) + if err != nil { + return nil, "", fmt.Errorf("parse client public key: %w", err) + } + + publicKey, ok = parsed.(ed25519.PublicKey) + if !ok { + return nil, "", fmt.Errorf("client key is not ed25519") + } + + fingerprint, err = publicKeyFingerprint(publicKey) + if err != nil { + return nil, "", err + } + + return publicKey, fingerprint, nil +} + +func pairingInsert(name, fingerprint string, publicKey []byte, now time.Time) (*PairingRequest, error) { + var request PairingRequest + var attempts int + + var err error + + request = PairingRequest{ + Id: uuid.NewString(), + Name: name, + Fingerprint: fingerprint, + PublicKey: publicKey, + CreatedAt: now.Unix(), + ExpiresAt: now.Add(pairingLifetime).Unix(), + Status: pairingWaiting, + } + + for attempts = 0; attempts < 10; attempts++ { + request.Code, err = pairingCode() + if err != nil { + return nil, err + } + + _, err = util.DB.Exec(`INSERT INTO rpc_pairings + (id, code, name, fingerprint, public_key, created_at, expires_at, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?);`, + request.Id, request.Code, request.Name, request.Fingerprint, request.PublicKey, + request.CreatedAt, request.ExpiresAt, request.Status) + if err == nil { + return &request, nil + } + if !strings.Contains(strings.ToLower(err.Error()), "unique") { + return nil, err + } + } + + return nil, fmt.Errorf("could not allocate pairing code") +} + +func PairingBegin(name string, encodedPublicKey []byte) (*PairingRequest, error) { + var fingerprint string + var now time.Time + var pending int + + var err error + + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("device name is required") + } + if len(name) > maxDeviceNameBytes { + return nil, fmt.Errorf("device name exceeds %d bytes", maxDeviceNameBytes) + } + + _, fingerprint, err = pairingPublicKey(encodedPublicKey) + if err != nil { + return nil, err + } + + now = time.Now() + _, err = util.DB.Exec("UPDATE rpc_pairings SET status = ? WHERE status = ? AND expires_at <= ?;", pairingExpired, pairingWaiting, now.Unix()) + if err != nil { + return nil, err + } + _, err = util.DB.Exec("DELETE FROM rpc_pairings WHERE status != ? AND expires_at <= ?;", pairingWaiting, now.Add(-pairingRetention).Unix()) + if err != nil { + return nil, err + } + err = util.DB.QueryRow("SELECT COUNT(*) FROM rpc_pairings WHERE status = ?;", pairingWaiting).Scan(&pending) + if err != nil { + return nil, err + } + if pending >= maxPendingPairings { + return nil, fmt.Errorf("too many pairing requests are waiting") + } + + return pairingInsert(name, fingerprint, append([]byte(nil), encodedPublicKey...), now) +} + +func pairingScan(row *sql.Row) (*PairingRequest, error) { + var request PairingRequest + + var err error + + err = row.Scan(&request.Id, &request.Code, &request.Name, &request.Fingerprint, &request.PublicKey, + &request.CertificatePEM, &request.CreatedAt, &request.ExpiresAt, &request.Status) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("pairing request not found") + } + + return nil, err + } + + if request.Status == pairingWaiting && request.ExpiresAt <= time.Now().Unix() { + request.Status = pairingExpired + _, err = util.DB.Exec("UPDATE rpc_pairings SET status = ? WHERE id = ? AND status = ?;", pairingExpired, request.Id, pairingWaiting) + if err != nil { + return nil, err + } + } + + return &request, nil +} + +func PairingGet(id string) (*PairingRequest, error) { + return pairingScan(util.DB.QueryRow(`SELECT id, code, name, fingerprint, public_key, certificate_pem, + created_at, expires_at, status FROM rpc_pairings WHERE id = ?;`, id)) +} + +func PairingPending() ([]*PairingRequest, error) { + var rows *sql.Rows + var request PairingRequest + var requests []*PairingRequest + var now int64 + + var err error + + now = time.Now().Unix() + _, err = util.DB.Exec("UPDATE rpc_pairings SET status = ? WHERE status = ? AND expires_at <= ?;", pairingExpired, pairingWaiting, now) + if err != nil { + return nil, err + } + + rows, err = util.DB.Query(`SELECT id, code, name, fingerprint, public_key, certificate_pem, + created_at, expires_at, status FROM rpc_pairings WHERE status = ? ORDER BY created_at ASC;`, pairingWaiting) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + err = rows.Scan(&request.Id, &request.Code, &request.Name, &request.Fingerprint, &request.PublicKey, + &request.CertificatePEM, &request.CreatedAt, &request.ExpiresAt, &request.Status) + if err != nil { + return nil, err + } + + requests = append(requests, &PairingRequest{Id: request.Id, Code: request.Code, Name: request.Name, + Fingerprint: request.Fingerprint, PublicKey: request.PublicKey, CreatedAt: request.CreatedAt, + ExpiresAt: request.ExpiresAt, Status: request.Status}) + } + + err = rows.Err() + if err != nil { + return nil, err + } + + return requests, nil +} + +func PairingApprove(code string) (*ClientDevice, error) { + var request *PairingRequest + var certificate []byte + var serial string + var client ClientDevice + var now int64 + var tx *sql.Tx + + var err error + + request, err = pairingScan(util.DB.QueryRow(`SELECT id, code, name, fingerprint, public_key, certificate_pem, + created_at, expires_at, status FROM rpc_pairings WHERE code = ?;`, code)) + if err != nil { + return nil, err + } + if request.Status != pairingWaiting { + return nil, fmt.Errorf("pairing request is %s", request.Status) + } + + client = ClientDevice{Id: uuid.NewString(), Name: request.Name, Fingerprint: request.Fingerprint} + certificate, serial, err = IssueClientCertificate(request.PublicKey, client.Id) + if err != nil { + return nil, err + } + + now = time.Now().Unix() + client.CertificateSerial = serial + client.PairedAt = now + + tx, err = util.DB.Begin() + if err != nil { + return nil, err + } + + _, err = tx.Exec(`INSERT INTO rpc_clients + (id, name, fingerprint, public_key, certificate_serial, paired_at, last_seen_at, revoked_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 0) + ON CONFLICT(fingerprint) DO UPDATE SET id = excluded.id, name = excluded.name, public_key = excluded.public_key, + certificate_serial = excluded.certificate_serial, paired_at = excluded.paired_at, revoked_at = 0;`, + client.Id, client.Name, client.Fingerprint, request.PublicKey, client.CertificateSerial, client.PairedAt) + if err != nil { + tx.Rollback() + return nil, err + } + + _, err = tx.Exec("UPDATE rpc_pairings SET status = ?, certificate_pem = ? WHERE id = ? AND status = ?;", + pairingApproved, certificate, request.Id, pairingWaiting) + if err != nil { + tx.Rollback() + return nil, err + } + + err = tx.Commit() + if err != nil { + return nil, err + } + + return &client, nil +} + +func PairingDeny(code string) error { + var result sql.Result + var affected int64 + + var err error + + result, err = util.DB.Exec("UPDATE rpc_pairings SET status = ? WHERE code = ? AND status = ?;", pairingDenied, code, pairingWaiting) + if err != nil { + return err + } + + affected, err = result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("waiting pairing request not found") + } + + return nil +} + +func ClientList() ([]*ClientDevice, error) { + var rows *sql.Rows + var client ClientDevice + var clients []*ClientDevice + + var err error + + rows, err = util.DB.Query(`SELECT id, name, fingerprint, certificate_serial, paired_at, last_seen_at, revoked_at + FROM rpc_clients ORDER BY paired_at ASC;`) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + err = rows.Scan(&client.Id, &client.Name, &client.Fingerprint, &client.CertificateSerial, + &client.PairedAt, &client.LastSeenAt, &client.RevokedAt) + if err != nil { + return nil, err + } + + clients = append(clients, &ClientDevice{Id: client.Id, Name: client.Name, Fingerprint: client.Fingerprint, + CertificateSerial: client.CertificateSerial, PairedAt: client.PairedAt, + LastSeenAt: client.LastSeenAt, RevokedAt: client.RevokedAt}) + } + + err = rows.Err() + if err != nil { + return nil, err + } + + return clients, nil +} + +func ClientRevoke(identifier string) error { + var result sql.Result + var affected int64 + + var err error + + result, err = util.DB.Exec("UPDATE rpc_clients SET revoked_at = ? WHERE id = ? OR fingerprint = ?;", time.Now().Unix(), identifier, identifier) + if err != nil { + return err + } + + affected, err = result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return fmt.Errorf("client not found") + } + + return nil +} + +func ClientAuthenticate(certificate *x509.Certificate) (*ClientDevice, error) { + var fingerprint string + var client ClientDevice + + var err error + + fingerprint, err = certificateFingerprint(certificate) + if err != nil { + return nil, err + } + + err = util.DB.QueryRow(`SELECT id, name, fingerprint, certificate_serial, paired_at, last_seen_at, revoked_at + FROM rpc_clients WHERE fingerprint = ?;`, fingerprint).Scan(&client.Id, &client.Name, &client.Fingerprint, + &client.CertificateSerial, &client.PairedAt, &client.LastSeenAt, &client.RevokedAt) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("client is not paired") + } + + return nil, err + } + if client.RevokedAt != 0 { + return nil, fmt.Errorf("client is revoked") + } + if certificate.SerialNumber.String() != client.CertificateSerial { + return nil, fmt.Errorf("client certificate has been replaced") + } + + client.LastSeenAt = time.Now().Unix() + _, err = util.DB.Exec("UPDATE rpc_clients SET last_seen_at = ? WHERE id = ?;", client.LastSeenAt, client.Id) + if err != nil { + return nil, err + } + + return &client, nil +} diff --git a/rpc/pairing_service.go b/rpc/pairing_service.go new file mode 100644 index 0000000..79668fe --- /dev/null +++ b/rpc/pairing_service.go @@ -0,0 +1,164 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "net" + "sync" + "time" + + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" +) + +type pairingRate struct { + Started time.Time + Count int +} + +type pairingService struct { + mininaruv1.UnimplementedPairingServiceServer + + identity *ServerIdentity + rates map[string]pairingRate + mu sync.Mutex +} + +const pairingPollInterval = 250 * time.Millisecond + +const pairingRateWindow = time.Minute + +const pairingRateLimit = 5 + +func pairingState(value string) mininaruv1.PairingState { + switch value { + case pairingWaiting: + return mininaruv1.PairingState_PAIRING_STATE_WAITING + case pairingApproved: + return mininaruv1.PairingState_PAIRING_STATE_APPROVED + case pairingDenied: + return mininaruv1.PairingState_PAIRING_STATE_DENIED + default: + return mininaruv1.PairingState_PAIRING_STATE_EXPIRED + } +} + +func pairingPeer(ctx context.Context) string { + var remote *peer.Peer + var found bool + var host string + + var err error + + remote, found = peer.FromContext(ctx) + if !found || remote.Addr == nil { + return "unknown" + } + + host, _, err = net.SplitHostPort(remote.Addr.String()) + if err != nil { + return remote.Addr.String() + } + + return host +} + +func (s *pairingService) allowPairing(ctx context.Context) bool { + var key string + var current pairingRate + var now time.Time + + key = pairingPeer(ctx) + now = time.Now() + + s.mu.Lock() + defer s.mu.Unlock() + + current = s.rates[key] + if current.Started.IsZero() || now.Sub(current.Started) >= pairingRateWindow { + current = pairingRate{Started: now} + } + if current.Count >= pairingRateLimit { + s.rates[key] = current + return false + } + + current.Count++ + s.rates[key] = current + + return true +} + +func (s *pairingService) Begin(ctx context.Context, request *mininaruv1.BeginPairingRequest) (*mininaruv1.BeginPairingResponse, error) { + var pairing *PairingRequest + + var err error + + if !s.allowPairing(ctx) { + return nil, status.Error(codes.ResourceExhausted, "too many pairing requests") + } + + pairing, err = PairingBegin(request.GetDeviceName(), request.GetPublicKey()) + if err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) + } + + return &mininaruv1.BeginPairingResponse{RequestId: pairing.Id, PairingCode: pairing.Code, + ClientFingerprint: pairing.Fingerprint, ExpiresAtUnix: pairing.ExpiresAt}, nil +} + +func pairingEvent(request *PairingRequest, ca []byte) *mininaruv1.PairingEvent { + var event mininaruv1.PairingEvent + + event.State = pairingState(request.Status) + if request.Status == pairingApproved { + event.ClientCertificatePem = request.CertificatePEM + event.CaCertificatePem = ca + } + + return &event +} + +func (s *pairingService) Watch(request *mininaruv1.WatchPairingRequest, stream mininaruv1.PairingService_WatchServer) error { + var ticker *time.Ticker + var pairing *PairingRequest + var last string + var event *mininaruv1.PairingEvent + + var err error + + if request.GetRequestId() == "" { + return status.Error(codes.InvalidArgument, "request id is required") + } + + ticker = time.NewTicker(pairingPollInterval) + defer ticker.Stop() + + for { + pairing, err = PairingGet(request.GetRequestId()) + if err != nil { + return status.Error(codes.NotFound, err.Error()) + } + if pairing.Status != last { + event = pairingEvent(pairing, s.identity.CAPEM) + err = stream.Send(event) + if err != nil { + return err + } + last = pairing.Status + } + if pairing.Status != pairingWaiting { + return nil + } + + select { + case <-ticker.C: + case <-stream.Context().Done(): + return stream.Context().Err() + } + } +} diff --git a/rpc/pairing_test.go b/rpc/pairing_test.go new file mode 100644 index 0000000..05419fe --- /dev/null +++ b/rpc/pairing_test.go @@ -0,0 +1,674 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/pem" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/devproje/mininaru/config" + "github.com/devproje/mininaru/core" + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "github.com/devproje/mininaru/util" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type testChatStream struct { + ctx context.Context + incoming chan *mininaruv1.ChatClientEvent + outgoing []*mininaruv1.ChatServerEvent + autoDeny bool + mu sync.Mutex +} + +func (s *testChatStream) Send(event *mininaruv1.ChatServerEvent) error { + s.mu.Lock() + s.outgoing = append(s.outgoing, event) + s.mu.Unlock() + if s.autoDeny && event.GetApproval() != nil { + s.incoming <- &mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Approval{Approval: &mininaruv1.ApprovalDecision{ + RequestId: event.GetApproval().GetRequestId(), Choice: mininaruv1.ApprovalChoice_APPROVAL_CHOICE_DENY}}} + } + + return nil +} + +func (s *testChatStream) Recv() (*mininaruv1.ChatClientEvent, error) { + var event *mininaruv1.ChatClientEvent + + select { + case event = <-s.incoming: + return event, nil + case <-s.ctx.Done(): + return nil, s.ctx.Err() + } +} + +func (s *testChatStream) SetHeader(headers metadata.MD) error { + return nil +} + +func (s *testChatStream) SendHeader(headers metadata.MD) error { + return nil +} + +func (s *testChatStream) SetTrailer(trailers metadata.MD) {} + +func (s *testChatStream) Context() context.Context { + return s.ctx +} + +func (s *testChatStream) SendMsg(message any) error { + return nil +} + +func (s *testChatStream) RecvMsg(message any) error { + return io.EOF +} + +func rpcTestSetup(t *testing.T) { + var directory string + + var err error + + t.Helper() + + directory = t.TempDir() + err = util.InitFS(directory) + if err != nil { + t.Fatal(err) + } + util.DB, err = util.InitDatabase(filepath.Join(directory, "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + util.DB.Close() + }) +} + +func testPublicKey(t *testing.T) (ed25519.PrivateKey, []byte) { + var publicKey ed25519.PublicKey + var privateKey ed25519.PrivateKey + var encoded []byte + + var err error + + t.Helper() + + publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + encoded, err = x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + t.Fatal(err) + } + + return privateKey, encoded +} + +func testClientCertificate(t *testing.T, certificatePEM []byte, privateKey ed25519.PrivateKey) tls.Certificate { + var key []byte + var keyPEM []byte + var certificate tls.Certificate + + var err error + + t.Helper() + + key, err = x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + t.Fatal(err) + } + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: key}) + certificate, err = tls.X509KeyPair(certificatePEM, keyPEM) + if err != nil { + t.Fatal(err) + } + + return certificate +} + +func TestServerIdentityStableAndPrivate(t *testing.T) { + var first *ServerIdentity + var second *ServerIdentity + var info os.FileInfo + + var err error + + rpcTestSetup(t) + + first, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + second, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + if first.Fingerprint != second.Fingerprint { + t.Fatalf("fingerprint changed: %s != %s", first.Fingerprint, second.Fingerprint) + } + + info, err = os.Stat(filepath.Join(util.Path(pkiDirectory), caPrivateKeyFile)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("ca key mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestPairingApprovesAuthenticatesAndRevokes(t *testing.T) { + var privateKey ed25519.PrivateKey + var publicKey []byte + var request *PairingRequest + var device *ClientDevice + var approved *PairingRequest + var block *pem.Block + var certificate *x509.Certificate + + var err error + + rpcTestSetup(t) + _, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + privateKey, publicKey = testPublicKey(t) + if len(privateKey) == 0 { + t.Fatal("client private key is empty") + } + + request, err = PairingBegin("laptop", publicKey) + if err != nil { + t.Fatal(err) + } + device, err = PairingApprove(request.Code) + if err != nil { + t.Fatal(err) + } + approved, err = PairingGet(request.Id) + if err != nil { + t.Fatal(err) + } + if approved.Status != pairingApproved || len(approved.CertificatePEM) == 0 { + t.Fatalf("approved pairing = %#v", approved) + } + + block, _ = pem.Decode(approved.CertificatePEM) + if block == nil { + t.Fatal("client certificate is not pem") + } + certificate, err = x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatal(err) + } + _, err = ClientAuthenticate(certificate) + if err != nil { + t.Fatal(err) + } + + err = ClientRevoke(device.Fingerprint) + if err != nil { + t.Fatal(err) + } + _, err = ClientAuthenticate(certificate) + if err == nil { + t.Fatal("revoked client authenticated") + } +} + +func TestPairingBeginIsRateLimitedByPeer(t *testing.T) { + var service pairingService + var publicKey []byte + var ctx context.Context + var index int + + var err error + + rpcTestSetup(t) + _, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + _, publicKey = testPublicKey(t) + service.rates = make(map[string]pairingRate) + ctx = peer.NewContext(context.Background(), &peer.Peer{Addr: &net.TCPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234}}) + + for index = 0; index < pairingRateLimit; index++ { + _, err = service.Begin(ctx, &mininaruv1.BeginPairingRequest{DeviceName: "client", PublicKey: publicKey}) + if err != nil { + t.Fatal(err) + } + } + _, err = service.Begin(ctx, &mininaruv1.BeginPairingRequest{DeviceName: "client", PublicKey: publicKey}) + if status.Code(err) != codes.ResourceExhausted { + t.Fatalf("rate limit code = %v, want %v", status.Code(err), codes.ResourceExhausted) + } +} + +func TestGRPCRequiresActivePairedCertificate(t *testing.T) { + var identity *ServerIdentity + var registry *core.Registry + var server *grpc.Server + var listener *bufconn.Listener + var unauthenticated *grpc.ClientConn + var privateKey ed25519.PrivateKey + var publicKey []byte + var request *PairingRequest + var device *ClientDevice + var approved *PairingRequest + var certificate tls.Certificate + var config tls.Config + var authenticated *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + + var err error + + rpcTestSetup(t) + identity, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + registry = core.NewRegistry() + server, err = NewServer(identity, registry) + if err != nil { + t.Fatal(err) + } + listener = bufconn.Listen(1 << 20) + go server.Serve(listener) + t.Cleanup(server.Stop) + + unauthenticated, err = grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, address string) (net.Conn, error) { + return listener.DialContext(ctx) + }), grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + RootCAs: identity.CAPool, ServerName: "localhost", MinVersion: tls.VersionTLS13, + }))) + if err != nil { + t.Fatal(err) + } + client = mininaruv1.NewMininaruServiceClient(unauthenticated) + _, err = client.ListAgents(context.Background(), &mininaruv1.ListAgentsRequest{}) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("unauthenticated code = %v, want %v", status.Code(err), codes.Unauthenticated) + } + unauthenticated.Close() + + privateKey, publicKey = testPublicKey(t) + request, err = PairingBegin("desktop", publicKey) + if err != nil { + t.Fatal(err) + } + device, err = PairingApprove(request.Code) + if err != nil { + t.Fatal(err) + } + approved, err = PairingGet(request.Id) + if err != nil { + t.Fatal(err) + } + certificate = testClientCertificate(t, approved.CertificatePEM, privateKey) + config = tls.Config{Certificates: []tls.Certificate{certificate}, RootCAs: identity.CAPool, + ServerName: "localhost", MinVersion: tls.VersionTLS13} + + authenticated, err = grpc.NewClient("passthrough:///bufnet", + grpc.WithContextDialer(func(ctx context.Context, address string) (net.Conn, error) { + return listener.DialContext(ctx) + }), grpc.WithTransportCredentials(credentials.NewTLS(&config))) + if err != nil { + t.Fatal(err) + } + defer authenticated.Close() + + client = mininaruv1.NewMininaruServiceClient(authenticated) + _, err = client.ListAgents(context.Background(), &mininaruv1.ListAgentsRequest{}) + if err != nil { + t.Fatal(err) + } + + err = ClientRevoke(device.Fingerprint) + if err != nil { + t.Fatal(err) + } + _, err = client.ListAgents(context.Background(), &mininaruv1.ListAgentsRequest{}) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("revoked code = %v, want %v", status.Code(err), codes.PermissionDenied) + } +} + +func TestPairClientTrustsServerAndDialsWithIssuedIdentity(t *testing.T) { + var identity *ServerIdentity + var registry *core.Registry + var server *grpc.Server + var listener net.Listener + var address string + var fingerprint string + var connection *grpc.ClientConn + var client mininaruv1.MininaruServiceClient + + var err error + + rpcTestSetup(t) + identity, err = LoadServerIdentity() + if err != nil { + t.Fatal(err) + } + registry = core.NewRegistry() + server, err = NewServer(identity, registry) + if err != nil { + t.Fatal(err) + } + listener, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go server.Serve(listener) + defer server.Stop() + + address = listener.Addr().String() + fingerprint, err = ServerFingerprint(context.Background(), address) + if err != nil { + t.Fatal(err) + } + if fingerprint != identity.Fingerprint { + t.Fatalf("fingerprint = %s, want %s", fingerprint, identity.Fingerprint) + } + + err = Pair(context.Background(), address, "laptop", fingerprint, func(request *PairingRequest) { + _, err = PairingApprove(request.Code) + }) + if err != nil { + t.Fatal(err) + } + + connection, err = Dial(context.Background(), address) + if err != nil { + t.Fatal(err) + } + defer connection.Close() + client = mininaruv1.NewMininaruServiceClient(connection) + _, err = client.ListAgents(context.Background(), &mininaruv1.ListAgentsRequest{}) + if err != nil { + t.Fatal(err) + } +} + +func chatRegistry(t *testing.T, upstream string) *core.Registry { + var registry *core.Registry + + var err error + + t.Helper() + + core.Providers = nil + core.DefaultProvider = nil + core.Agents = nil + core.Global = nil + core.ProviderCreate(core.Provider{Name: "local", BaseURL: upstream, ApiKey: "key"}) + core.Global = core.AgentNew("naru", "", "", "model", core.Providers[0]) + err = core.ProviderSave() + if err != nil { + t.Fatal(err) + } + err = core.AgentSave() + if err != nil { + t.Fatal(err) + } + + registry = core.NewRegistry() + err = registry.Reload() + if err != nil { + t.Fatal(err) + } + + return registry +} + +func TestChatStreamsAndPersistsServerSession(t *testing.T) { + var upstream *httptest.Server + var registry *core.Registry + var instance *core.Instance + var session *core.Session + var ctx context.Context + var cancel context.CancelFunc + var stream testChatStream + var event *mininaruv1.ChatServerEvent + var content string + var reasoning string + var completed *mininaruv1.ChatCompleted + var messages []*core.Message + + var err error + + rpcTestSetup(t) + config.Client = config.ClientConfig{Thinking: config.Thinking{Level: config.ThinkingOff}, Tools: config.Tools{Enabled: false}} + + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, "data: {\"id\":\"chat\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"reasoning_content\":\"thought\",\"content\":\"hello\"},\"finish_reason\":\"stop\"}]}\n\n") + io.WriteString(w, "data: {\"id\":\"chat\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"model\",\"choices\":[],\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":1,\"total_tokens\":5}}\n\n") + io.WriteString(w, "data: [DONE]\n\n") + })) + defer upstream.Close() + + registry = chatRegistry(t, upstream.URL) + instance, err = registry.Get("naru") + if err != nil { + t.Fatal(err) + } + session, err = instance.Session("remote") + if err != nil { + t.Fatal(err) + } + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + stream = testChatStream{ctx: ctx, incoming: make(chan *mininaruv1.ChatClientEvent, 1)} + stream.incoming <- &mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Start{Start: &mininaruv1.ChatStart{ + SessionId: session.Id, Content: "hi", Thinking: config.ThinkingOff}}} + + err = (&mininaruService{registry: registry, slots: make(chan struct{}, 1)}).Chat(&stream) + if err != nil { + t.Fatal(err) + } + + for _, event = range stream.outgoing { + if event.GetContent() != nil { + content = content + event.GetContent().GetText() + } + if event.GetReasoning() != nil { + reasoning = reasoning + event.GetReasoning().GetText() + } + if event.GetCompleted() != nil { + completed = event.GetCompleted() + } + } + if content != "hello" { + t.Fatalf("content = %q, want hello", content) + } + if reasoning != "thought" { + t.Fatalf("reasoning = %q, want thought", reasoning) + } + if completed == nil || completed.GetMessage().GetContent() != "hello" || completed.GetUsage().GetTotalTokens() != 5 { + t.Fatalf("completed = %#v", completed) + } + + messages, err = core.MessageList(session.Id) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 || messages[0].Content != "hi" || messages[1].Content != "hello" { + t.Fatalf("persisted messages = %#v", messages) + } +} + +func TestChatCarriesToolApprovalOverTheStream(t *testing.T) { + var calls atomic.Int32 + var upstream *httptest.Server + var registry *core.Registry + var instance *core.Instance + var session *core.Session + var ctx context.Context + var cancel context.CancelFunc + var stream testChatStream + var event *mininaruv1.ChatServerEvent + var requested bool + var completed *mininaruv1.ChatCompleted + + var err error + + rpcTestSetup(t) + config.Client = config.ClientConfig{Thinking: config.Thinking{Level: config.ThinkingOff}, Tools: config.Tools{Enabled: true}} + + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + if calls.Add(1) == 1 { + io.WriteString(w, "data: {\"id\":\"tool\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"name\":\"bash_exec\",\"arguments\":\"{\\\"command\\\":\\\"echo unsafe\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}]}\n\n") + io.WriteString(w, "data: [DONE]\n\n") + return + } + + io.WriteString(w, "data: {\"id\":\"answer\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"denied safely\"},\"finish_reason\":\"stop\"}]}\n\n") + io.WriteString(w, "data: {\"id\":\"answer\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"model\",\"choices\":[],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":2,\"total_tokens\":10}}\n\n") + io.WriteString(w, "data: [DONE]\n\n") + })) + defer upstream.Close() + + registry = chatRegistry(t, upstream.URL) + instance, err = registry.Get("naru") + if err != nil { + t.Fatal(err) + } + session, err = instance.Session("approval") + if err != nil { + t.Fatal(err) + } + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + stream = testChatStream{ctx: ctx, incoming: make(chan *mininaruv1.ChatClientEvent, 1), autoDeny: true} + stream.incoming <- &mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Start{Start: &mininaruv1.ChatStart{ + SessionId: session.Id, Content: "run it", Thinking: config.ThinkingOff}}} + + err = (&mininaruService{registry: registry, slots: make(chan struct{}, 1)}).Chat(&stream) + if err != nil { + t.Fatal(err) + } + for _, event = range stream.outgoing { + if event.GetApproval() != nil && event.GetApproval().GetToolName() == "bash_exec" { + requested = true + } + if event.GetCompleted() != nil { + completed = event.GetCompleted() + } + } + if !requested { + t.Fatal("dangerous tool produced no approval request") + } + if completed == nil || completed.GetMessage().GetContent() != "denied safely" { + t.Fatalf("completed = %#v", completed) + } + if calls.Load() != 2 { + t.Fatalf("upstream calls = %d, want tool round and answer round", calls.Load()) + } +} + +func TestChatCancellationReachesTheModelTurn(t *testing.T) { + var started chan struct{} + var release chan struct{} + var upstream *httptest.Server + var registry *core.Registry + var instance *core.Instance + var session *core.Session + var ctx context.Context + var cancel context.CancelFunc + var stream testChatStream + var result chan error + var statusValue string + + var err error + + rpcTestSetup(t) + config.Client = config.ClientConfig{Thinking: config.Thinking{Level: config.ThinkingOff}, Tools: config.Tools{Enabled: false}} + started = make(chan struct{}) + release = make(chan struct{}) + upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(started) + select { + case <-r.Context().Done(): + case <-release: + } + })) + defer func() { + close(release) + upstream.Close() + }() + + registry = chatRegistry(t, upstream.URL) + instance, err = registry.Get("naru") + if err != nil { + t.Fatal(err) + } + session, err = instance.Session("cancel") + if err != nil { + t.Fatal(err) + } + + ctx, cancel = context.WithCancel(context.Background()) + defer cancel() + stream = testChatStream{ctx: ctx, incoming: make(chan *mininaruv1.ChatClientEvent, 1)} + stream.incoming <- &mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Start{Start: &mininaruv1.ChatStart{ + SessionId: session.Id, Content: "wait", Thinking: config.ThinkingOff}}} + result = make(chan error, 1) + go func() { + result <- (&mininaruService{registry: registry, slots: make(chan struct{}, 1)}).Chat(&stream) + }() + + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("model request did not start") + } + stream.incoming <- &mininaruv1.ChatClientEvent{Event: &mininaruv1.ChatClientEvent_Cancel{Cancel: &mininaruv1.Empty{}}} + + select { + case err = <-result: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("cancelled chat did not stop") + } + + err = util.DB.QueryRow("SELECT status FROM messages WHERE session_id = ?;", session.Id).Scan(&statusValue) + if err != nil { + t.Fatal(err) + } + if statusValue != core.MessageCancelled { + t.Fatalf("message status = %s, want %s", statusValue, core.MessageCancelled) + } +} diff --git a/rpc/pki.go b/rpc/pki.go new file mode 100644 index 0000000..e8fd639 --- /dev/null +++ b/rpc/pki.go @@ -0,0 +1,368 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "time" + + "github.com/devproje/mininaru/util" +) + +type ServerIdentity struct { + Certificate tls.Certificate + CAPool *x509.CertPool + CAPEM []byte + Fingerprint string +} + +const pkiDirectory = "pki" + +const ( + caCertificateFile = "ca.crt" + caPrivateKeyFile = "ca.key" + serverCertificateFile = "server.crt" + serverPrivateKeyFile = "server.key" +) + +const ( + caLifetime = 10 * 365 * 24 * time.Hour + serverLifetime = 365 * 24 * time.Hour + clientLifetime = 365 * 24 * time.Hour +) + +func publicKeyFingerprint(publicKey any) (string, error) { + var encoded []byte + var sum [sha256.Size]byte + + var err error + + encoded, err = x509.MarshalPKIXPublicKey(publicKey) + if err != nil { + return "", err + } + + sum = sha256.Sum256(encoded) + + return "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]), nil +} + +func certificateFingerprint(certificate *x509.Certificate) (string, error) { + if certificate == nil { + return "", fmt.Errorf("certificate is required") + } + + return publicKeyFingerprint(certificate.PublicKey) +} + +func randomSerial() (*big.Int, error) { + var limit *big.Int + var serial *big.Int + + var err error + + limit = new(big.Int).Lsh(big.NewInt(1), 128) + serial, err = rand.Int(rand.Reader, limit) + if err != nil { + return nil, err + } + + return serial, nil +} + +func writePEM(path, kind string, bytes []byte, permission os.FileMode) error { + var encoded []byte + + encoded = pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: bytes}) + if len(encoded) == 0 { + return fmt.Errorf("encode %s", kind) + } + + return util.WriteFileAtomic(path, encoded, permission) +} + +func createCA(directory string) error { + var publicKey ed25519.PublicKey + var privateKey ed25519.PrivateKey + var serial *big.Int + var template x509.Certificate + var certificate []byte + var encodedKey []byte + var now time.Time + + var err error + + publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + + serial, err = randomSerial() + if err != nil { + return err + } + + now = time.Now() + template = x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "mininaru local ca"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(caLifetime), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, + IsCA: true, + BasicConstraintsValid: true, + } + + certificate, err = x509.CreateCertificate(rand.Reader, &template, &template, publicKey, privateKey) + if err != nil { + return err + } + + encodedKey, err = x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return err + } + + err = writePEM(filepath.Join(directory, caCertificateFile), "CERTIFICATE", certificate, 0600) + if err != nil { + return err + } + + return writePEM(filepath.Join(directory, caPrivateKeyFile), "PRIVATE KEY", encodedKey, 0600) +} + +func loadCA(directory string) (*x509.Certificate, ed25519.PrivateKey, []byte, error) { + var certificatePEM []byte + var keyPEM []byte + var certificateBlock *pem.Block + var keyBlock *pem.Block + var certificate *x509.Certificate + var parsedKey any + var privateKey ed25519.PrivateKey + var ok bool + + var err error + + certificatePEM, err = os.ReadFile(filepath.Join(directory, caCertificateFile)) + if err != nil { + return nil, nil, nil, err + } + + keyPEM, err = os.ReadFile(filepath.Join(directory, caPrivateKeyFile)) + if err != nil { + return nil, nil, nil, err + } + + certificateBlock, _ = pem.Decode(certificatePEM) + keyBlock, _ = pem.Decode(keyPEM) + if certificateBlock == nil || keyBlock == nil { + return nil, nil, nil, fmt.Errorf("invalid ca pem") + } + + certificate, err = x509.ParseCertificate(certificateBlock.Bytes) + if err != nil { + return nil, nil, nil, err + } + + parsedKey, err = x509.ParsePKCS8PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, nil, nil, err + } + + privateKey, ok = parsedKey.(ed25519.PrivateKey) + if !ok { + return nil, nil, nil, fmt.Errorf("ca key is not ed25519") + } + + return certificate, privateKey, certificatePEM, nil +} + +func createServerCertificate(directory string) error { + var ca *x509.Certificate + var caKey ed25519.PrivateKey + var publicKey ed25519.PublicKey + var privateKey ed25519.PrivateKey + var serial *big.Int + var template x509.Certificate + var certificate []byte + var encodedKey []byte + var now time.Time + + var err error + + ca, caKey, _, err = loadCA(directory) + if err != nil { + return err + } + + publicKey, privateKey, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + + serial, err = randomSerial() + if err != nil { + return err + } + + now = time.Now() + template = x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "mininaru server"}, + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(serverLifetime), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certificate, err = x509.CreateCertificate(rand.Reader, &template, ca, publicKey, caKey) + if err != nil { + return err + } + + encodedKey, err = x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return err + } + + err = writePEM(filepath.Join(directory, serverCertificateFile), "CERTIFICATE", certificate, 0600) + if err != nil { + return err + } + + return writePEM(filepath.Join(directory, serverPrivateKeyFile), "PRIVATE KEY", encodedKey, 0600) +} + +func ensurePKIFiles(directory string) error { + var err error + + err = os.MkdirAll(directory, 0700) + if err != nil { + return err + } + + _, err = os.Stat(filepath.Join(directory, caCertificateFile)) + if os.IsNotExist(err) { + err = createCA(directory) + } + if err != nil { + return err + } + + _, err = os.Stat(filepath.Join(directory, serverCertificateFile)) + if os.IsNotExist(err) { + err = createServerCertificate(directory) + } + + return err +} + +func LoadServerIdentity() (*ServerIdentity, error) { + var directory string + var identity ServerIdentity + var leaf *x509.Certificate + + var err error + + directory = util.Path(pkiDirectory) + err = ensurePKIFiles(directory) + if err != nil { + return nil, err + } + + identity.Certificate, err = tls.LoadX509KeyPair(filepath.Join(directory, serverCertificateFile), filepath.Join(directory, serverPrivateKeyFile)) + if err != nil { + return nil, err + } + + leaf, err = x509.ParseCertificate(identity.Certificate.Certificate[0]) + if err != nil { + return nil, err + } + identity.Certificate.Leaf = leaf + + _, _, identity.CAPEM, err = loadCA(directory) + if err != nil { + return nil, err + } + + identity.CAPool = x509.NewCertPool() + if !identity.CAPool.AppendCertsFromPEM(identity.CAPEM) { + return nil, fmt.Errorf("load ca certificate") + } + + identity.Fingerprint, err = certificateFingerprint(leaf) + if err != nil { + return nil, err + } + + return &identity, nil +} + +func IssueClientCertificate(publicKeyDER []byte, clientId string) ([]byte, string, error) { + var directory string + var ca *x509.Certificate + var caKey ed25519.PrivateKey + var parsedKey any + var publicKey ed25519.PublicKey + var ok bool + var serial *big.Int + var template x509.Certificate + var certificate []byte + var now time.Time + + var err error + + directory = util.Path(pkiDirectory) + ca, caKey, _, err = loadCA(directory) + if err != nil { + return nil, "", err + } + + parsedKey, err = x509.ParsePKIXPublicKey(publicKeyDER) + if err != nil { + return nil, "", err + } + + publicKey, ok = parsedKey.(ed25519.PublicKey) + if !ok { + return nil, "", fmt.Errorf("client key is not ed25519") + } + + serial, err = randomSerial() + if err != nil { + return nil, "", err + } + + now = time.Now() + template = x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: clientId}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(clientLifetime), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + certificate, err = x509.CreateCertificate(rand.Reader, &template, ca, publicKey, caKey) + if err != nil { + return nil, "", err + } + + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate}), serial.String(), nil +} diff --git a/rpc/server.go b/rpc/server.go new file mode 100644 index 0000000..065de9c --- /dev/null +++ b/rpc/server.go @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "strconv" + "time" + + "github.com/devproje/mininaru/core" + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "github.com/devproje/mininaru/util" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" +) + +type Config struct { + Host string + Port int +} + +const ( + DefaultHost = "127.0.0.1" + DefaultPort = 9090 +) + +const ( + maxReceiveMessageBytes = 20 << 20 + maxSendMessageBytes = 20 << 20 + gracefulStopTimeout = 5 * time.Second +) + +func tlsConfig(identity *ServerIdentity) *tls.Config { + return &tls.Config{ + Certificates: []tls.Certificate{identity.Certificate}, + ClientAuth: tls.VerifyClientCertIfGiven, + ClientCAs: identity.CAPool, + MinVersion: tls.VersionTLS13, + } +} + +func NewServer(identity *ServerIdentity, registry *core.Registry) (*grpc.Server, error) { + var server *grpc.Server + + if identity == nil { + return nil, fmt.Errorf("server identity is required") + } + if registry == nil { + return nil, fmt.Errorf("registry is required") + } + + server = grpc.NewServer( + grpc.Creds(credentials.NewTLS(tlsConfig(identity))), + grpc.UnaryInterceptor(unaryAuthenticate), + grpc.StreamInterceptor(streamAuthenticate), + grpc.MaxRecvMsgSize(maxReceiveMessageBytes), + grpc.MaxSendMsgSize(maxSendMessageBytes), + ) + mininaruv1.RegisterPairingServiceServer(server, &pairingService{identity: identity, rates: make(map[string]pairingRate)}) + mininaruv1.RegisterMininaruServiceServer(server, &mininaruService{registry: registry, slots: make(chan struct{}, maxConcurrentChats)}) + + return server, nil +} + +func gracefulStop(server *grpc.Server) { + var stopped chan struct{} + var timer *time.Timer + + stopped = make(chan struct{}) + go func() { + server.GracefulStop() + close(stopped) + }() + + timer = time.NewTimer(gracefulStopTimeout) + defer timer.Stop() + + select { + case <-stopped: + case <-timer.C: + server.Stop() + } +} + +func Serve(ctx context.Context, cfg Config, registry *core.Registry) error { + var identity *ServerIdentity + var address string + var listener net.Listener + var server *grpc.Server + var errs chan error + + var err error + + if cfg.Host == "" { + cfg.Host = DefaultHost + } + if cfg.Port == 0 { + cfg.Port = DefaultPort + } + + identity, err = LoadServerIdentity() + if err != nil { + return err + } + + address = net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)) + listener, err = net.Listen("tcp", address) + if err != nil { + return err + } + defer listener.Close() + + server, err = NewServer(identity, registry) + if err != nil { + return err + } + + errs = make(chan error, 1) + go func() { + errs <- server.Serve(listener) + }() + + util.Log.Info("grpc server listening", "address", listener.Addr().String(), "fingerprint", identity.Fingerprint) + + select { + case err = <-errs: + return err + case <-ctx.Done(): + } + + gracefulStop(server) + util.Log.Info("grpc server stopped") + + return nil +} diff --git a/rpc/service.go b/rpc/service.go new file mode 100644 index 0000000..9b4eb0e --- /dev/null +++ b/rpc/service.go @@ -0,0 +1,538 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package rpc + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/devproje/mininaru/config" + "github.com/devproje/mininaru/core" + "github.com/devproje/mininaru/modules" + mininaruv1 "github.com/devproje/mininaru/rpc/gen/mininaru/v1" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type mininaruService struct { + mininaruv1.UnimplementedMininaruServiceServer + + registry *core.Registry + slots chan struct{} +} + +const defaultSessionNameLayout = "2006-01-02 15:04" + +const maxConcurrentChats = 16 + +const maxChatContentBytes = 1 << 20 + +func rpcAgent(agent *core.NaruAgent) *mininaruv1.Agent { + var provider *core.Provider + var providerName string + + var err error + + if agent == nil { + return nil + } + + provider, err = core.ProviderFind(agent.ProviderId) + if err == nil { + providerName = provider.Name + } + + return &mininaruv1.Agent{Id: agent.Id, Name: agent.Name, Model: agent.Model, Provider: providerName} +} + +func rpcSession(session *core.Session) *mininaruv1.Session { + if session == nil { + return nil + } + + return &mininaruv1.Session{Id: session.Id, AgentId: session.AgentId, Name: session.Name} +} + +func rpcMessage(message *core.Message) *mininaruv1.Message { + if message == nil { + return nil + } + + return &mininaruv1.Message{Id: message.Id, SessionId: message.SessionId, Role: message.Role, + Content: message.Content, Reasoning: message.Reasoning, Status: message.Status, Error: message.Error} +} + +func rpcToolCall(call *core.ToolCall) *mininaruv1.ToolCall { + if call == nil { + return nil + } + + return &mininaruv1.ToolCall{Id: call.Id, CallId: call.CallId, MessageId: call.MessageId, Name: call.Name, + Arguments: call.Arguments, Result: call.Result, Status: call.Status, Error: call.Error} +} + +func rpcUsage(totals *core.UsageTotals) *mininaruv1.Usage { + var usage mininaruv1.Usage + var line core.UsageLine + + if totals == nil { + return &usage + } + + usage = mininaruv1.Usage{SessionId: totals.SessionId, PromptTokens: totals.PromptTokens, + CompletionTokens: totals.CompletionTokens, TotalTokens: totals.TotalTokens, + CachedTokens: totals.CachedTokens, CacheWriteTokens: totals.CacheWriteTokens} + for _, line = range totals.Lines { + usage.Lines = append(usage.Lines, &mininaruv1.UsageLine{Kind: line.Kind, PromptTokens: line.PromptTokens, + CompletionTokens: line.CompletionTokens, TotalTokens: line.TotalTokens, + CachedTokens: line.CachedTokens, CacheWriteTokens: line.CacheWriteTokens}) + } + + return &usage +} + +func sessionInstance(registry *core.Registry, sessionId string) (*core.Session, *core.Instance, error) { + var session *core.Session + var instance *core.Instance + + var err error + + if sessionId == "" { + return nil, nil, status.Error(codes.InvalidArgument, "session id is required") + } + + session, err = core.SessionFind(sessionId) + if err != nil { + return nil, nil, status.Error(codes.NotFound, err.Error()) + } + + instance, err = registry.ByAgentId(session.AgentId) + if err != nil { + return nil, nil, status.Error(codes.FailedPrecondition, err.Error()) + } + + return session, instance, nil +} + +func (s *mininaruService) ListAgents(ctx context.Context, request *mininaruv1.ListAgentsRequest) (*mininaruv1.ListAgentsResponse, error) { + var response mininaruv1.ListAgentsResponse + var instance *core.Instance + + for _, instance = range s.registry.List() { + response.Agents = append(response.Agents, rpcAgent(instance.Agent)) + } + if core.Global != nil { + response.DefaultAgentId = core.Global.Id + } + + return &response, nil +} + +func (s *mininaruService) ListSessions(ctx context.Context, request *mininaruv1.ListSessionsRequest) (*mininaruv1.ListSessionsResponse, error) { + var instance *core.Instance + var sessions []*core.Session + var session *core.Session + var response mininaruv1.ListSessionsResponse + + var err error + + if request.GetAgent() == "" { + instance, err = s.registry.Default() + } else { + instance, err = s.registry.Get(request.GetAgent()) + } + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + sessions, err = core.SessionList(instance.Agent.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + for _, session = range sessions { + response.Sessions = append(response.Sessions, rpcSession(session)) + } + + return &response, nil +} + +func (s *mininaruService) CreateSession(ctx context.Context, request *mininaruv1.CreateSessionRequest) (*mininaruv1.Session, error) { + var instance *core.Instance + var name string + var session *core.Session + + var err error + + if request.GetAgent() == "" { + instance, err = s.registry.Default() + } else { + instance, err = s.registry.Get(request.GetAgent()) + } + if err != nil { + return nil, status.Error(codes.NotFound, err.Error()) + } + + name = request.GetName() + if name == "" { + name = time.Now().Format(defaultSessionNameLayout) + } + + session, err = instance.Session(name) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return rpcSession(session), nil +} + +func (s *mininaruService) GetSession(ctx context.Context, request *mininaruv1.GetSessionRequest) (*mininaruv1.SessionDetail, error) { + var session *core.Session + var instance *core.Instance + var messages []*core.Message + var response mininaruv1.SessionDetail + var message *core.Message + var calls []*core.ToolCall + var call *core.ToolCall + var tokens int64 + var window int64 + var known bool + + var err error + + session, instance, err = sessionInstance(s.registry, request.GetSessionId()) + if err != nil { + return nil, err + } + + messages, err = core.MessageList(session.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + response.Session = rpcSession(session) + response.Agent = rpcAgent(instance.Agent) + for _, message = range messages { + response.Messages = append(response.Messages, rpcMessage(message)) + calls, err = core.ToolCallList(message.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + for _, call = range calls { + response.ToolCalls = append(response.ToolCalls, rpcToolCall(call)) + } + } + + tokens, window, known, err = core.SessionContextTokens(session.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + response.ContextTokens = tokens + response.ContextWindow = window + response.ContextKnown = known + + return &response, nil +} + +func (s *mininaruService) RenameSession(ctx context.Context, request *mininaruv1.RenameSessionRequest) (*mininaruv1.Session, error) { + var session *core.Session + + var err error + + if request.GetName() == "" { + return nil, status.Error(codes.InvalidArgument, "session name is required") + } + + session, _, err = sessionInstance(s.registry, request.GetSessionId()) + if err != nil { + return nil, err + } + + err = core.SessionUpdate(session.Id, request.GetName()) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + session.Name = request.GetName() + + return rpcSession(session), nil +} + +func (s *mininaruService) DeleteSession(ctx context.Context, request *mininaruv1.DeleteSessionRequest) (*mininaruv1.Empty, error) { + var session *core.Session + + var err error + + session, _, err = sessionInstance(s.registry, request.GetSessionId()) + if err != nil { + return nil, err + } + + err = core.SessionDelete(session.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return &mininaruv1.Empty{}, nil +} + +func (s *mininaruService) GetUsage(ctx context.Context, request *mininaruv1.GetUsageRequest) (*mininaruv1.Usage, error) { + var session *core.Session + var totals *core.UsageTotals + + var err error + + session, _, err = sessionInstance(s.registry, request.GetSessionId()) + if err != nil { + return nil, err + } + + totals, err = core.SessionUsage(session.Id) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return rpcUsage(totals), nil +} + +func (s *mininaruService) CompactSession(ctx context.Context, request *mininaruv1.CompactSessionRequest) (*mininaruv1.CompactSessionResponse, error) { + var session *core.Session + var instance *core.Instance + var compacted bool + + var err error + + session, instance, err = sessionInstance(s.registry, request.GetSessionId()) + if err != nil { + return nil, err + } + + compacted, err = core.CompactNow(ctx, instance.Agent, session) + if err != nil { + return nil, status.Error(codes.Internal, err.Error()) + } + + return &mininaruv1.CompactSessionResponse{Compacted: compacted}, nil +} + +func chatContentEvent(text string) *mininaruv1.ChatServerEvent { + return &mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Content{Content: &mininaruv1.TextDelta{Text: text}}} +} + +func chatReasoningEvent(text string) *mininaruv1.ChatServerEvent { + return &mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Reasoning{Reasoning: &mininaruv1.TextDelta{Text: text}}} +} + +func chatToolEvent(event core.ToolEvent) *mininaruv1.ChatServerEvent { + return &mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Tool{Tool: &mininaruv1.ToolEvent{ + Phase: event.Phase, CallId: event.CallId, Name: event.Name, Arguments: event.Arguments, + Result: event.Result, Status: event.Status, Error: event.Error}}} +} + +func receiveChat(stream mininaruv1.MininaruService_ChatServer, incoming chan<- *mininaruv1.ChatClientEvent, cancel context.CancelFunc) { + var event *mininaruv1.ChatClientEvent + + var err error + + for { + event, err = stream.Recv() + if err != nil { + cancel() + return + } + if event.GetCancel() != nil { + cancel() + return + } + + select { + case incoming <- event: + case <-stream.Context().Done(): + return + } + } +} + +func approvalChoice(choice mininaruv1.ApprovalChoice) (bool, bool) { + if choice == mininaruv1.ApprovalChoice_APPROVAL_CHOICE_SESSION { + return true, true + } + if choice == mininaruv1.ApprovalChoice_APPROVAL_CHOICE_ONCE { + return true, false + } + + return false, false +} + +func chatApprover(ctx context.Context, stream mininaruv1.MininaruService_ChatServer, + incoming <-chan *mininaruv1.ChatClientEvent) core.ToolApprovalFunc { + var allowed map[string]bool + var sendMu sync.Mutex + + allowed = make(map[string]bool) + + return func(approvalCtx context.Context, def modules.Def, arguments string) (bool, error) { + var requestId string + var event *mininaruv1.ChatClientEvent + var decision *mininaruv1.ApprovalDecision + var allow bool + var remember bool + + var err error + + if allowed[def.Name] { + return true, nil + } + + requestId = uuid.NewString() + sendMu.Lock() + err = stream.Send(&mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Approval{Approval: &mininaruv1.ApprovalRequest{ + RequestId: requestId, ToolName: def.Name, Arguments: arguments}}}) + sendMu.Unlock() + if err != nil { + return false, err + } + + for { + select { + case event = <-incoming: + decision = event.GetApproval() + if decision == nil || decision.GetRequestId() != requestId { + continue + } + allow, remember = approvalChoice(decision.GetChoice()) + if remember { + allowed[def.Name] = true + } + return allow, nil + case <-approvalCtx.Done(): + return false, approvalCtx.Err() + case <-ctx.Done(): + return false, ctx.Err() + } + } + } +} + +func errorsIsContext(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func sendChatFailure(stream mininaruv1.MininaruService_ChatServer, err error) error { + var code string + + code = "chat_failed" + if errorsIsContext(err) { + code = "cancelled" + } + + return stream.Send(&mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Failed{Failed: &mininaruv1.ChatFailed{ + Code: code, Message: err.Error()}}}) +} + +func (s *mininaruService) Chat(stream mininaruv1.MininaruService_ChatServer) error { + var first *mininaruv1.ChatClientEvent + var start *mininaruv1.ChatStart + var session *core.Session + var instance *core.Instance + var chatCtx context.Context + var cancel context.CancelFunc + var incoming chan *mininaruv1.ChatClientEvent + var defs []modules.Def + var message *core.Message + var totals *core.UsageTotals + var sendMu sync.Mutex + var streamErr error + + var err error + + select { + case s.slots <- struct{}{}: + defer func() { <-s.slots }() + default: + return status.Error(codes.ResourceExhausted, "too many concurrent chats") + } + + first, err = stream.Recv() + if err != nil { + if err == io.EOF { + return status.Error(codes.InvalidArgument, "chat start is required") + } + return err + } + start = first.GetStart() + if start == nil || start.GetContent() == "" { + return status.Error(codes.InvalidArgument, "first event must contain a non-empty chat start") + } + if len(start.GetContent()) > maxChatContentBytes { + return status.Error(codes.ResourceExhausted, "chat content exceeds 1 MiB") + } + + session, instance, err = sessionInstance(s.registry, start.GetSessionId()) + if err != nil { + return err + } + + chatCtx, cancel = context.WithCancel(stream.Context()) + defer cancel() + incoming = make(chan *mininaruv1.ChatClientEvent, 1) + go receiveChat(stream, incoming, cancel) + + err = stream.Send(&mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Started{Started: &mininaruv1.ChatStarted{TurnId: uuid.NewString()}}}) + if err != nil { + return err + } + + if config.Client.Tools.Enabled { + defs = modules.DefaultTools() + } + + message, err = instance.ChatWithTools(chatCtx, session, start.GetContent(), defs, start.GetThinking(), + func(text string) { + sendMu.Lock() + if streamErr == nil { + streamErr = stream.Send(chatContentEvent(text)) + } + sendMu.Unlock() + if streamErr != nil { + cancel() + } + }, func(text string) { + sendMu.Lock() + if streamErr == nil { + streamErr = stream.Send(chatReasoningEvent(text)) + } + sendMu.Unlock() + if streamErr != nil { + cancel() + } + }, func(event core.ToolEvent) { + sendMu.Lock() + if streamErr == nil { + streamErr = stream.Send(chatToolEvent(event)) + } + sendMu.Unlock() + if streamErr != nil { + cancel() + } + }, chatApprover(chatCtx, stream, incoming)) + if streamErr != nil { + return streamErr + } + if err != nil { + return sendChatFailure(stream, err) + } + + totals, err = core.SessionUsage(session.Id) + if err != nil { + return sendChatFailure(stream, fmt.Errorf("read usage: %w", err)) + } + + return stream.Send(&mininaruv1.ChatServerEvent{Event: &mininaruv1.ChatServerEvent_Completed{Completed: &mininaruv1.ChatCompleted{ + Message: rpcMessage(message), Usage: rpcUsage(totals)}}}) +} diff --git a/scripts/generate-proto.sh b/scripts/generate-proto.sh new file mode 100644 index 0000000..36b12e0 --- /dev/null +++ b/scripts/generate-proto.sh @@ -0,0 +1,30 @@ +#!/bin/sh + +set -eu + +PROTOC=${PROTOC:-protoc} + +PATH="$(go env GOPATH)/bin:$PATH" +export PATH + +test "$("$PROTOC" --version)" = "libprotoc 35.0" +test "$(protoc-gen-go --version)" = "protoc-gen-go v1.36.11" +test "$(protoc-gen-go-grpc --version)" = "protoc-gen-go-grpc 1.6.2" + +"$PROTOC" -I api \ + --go_out=. --go_opt=module=github.com/devproje/mininaru \ + --go-grpc_out=. --go-grpc_opt=module=github.com/devproje/mininaru \ + api/mininaru/v1/mininaru.proto + +for file in rpc/gen/mininaru/v1/mininaru.pb.go rpc/gen/mininaru/v1/mininaru_grpc.pb.go; do + temp="${file}.tmp" + { + echo '// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO)' + echo '// SPDX-License-Identifier: GPL-3.0-or-later' + awk '!/^[[:space:]]*\/\//' "$file" + } > "$temp" + mv "$temp" "$file" +done + +gofmt -w rpc/gen/mininaru/v1/mininaru.pb.go rpc/gen/mininaru/v1/mininaru_grpc.pb.go +go run ./scripts/protostyle diff --git a/scripts/protostyle/main.go b/scripts/protostyle/main.go new file mode 100644 index 0000000..e58d7b1 --- /dev/null +++ b/scripts/protostyle/main.go @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: 2026 Wonhyeok Kim (Project_IO) +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "go/types" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +type local struct { + Name string + Type string + Err bool +} + +type rewriter struct { + info *types.Info + packagePath string + names map[types.Object]string + locals []local + used map[string]string + byBase map[string]string + changed bool +} + +func fieldNames(fields *ast.FieldList, used map[string]string) { + var field *ast.Field + var name *ast.Ident + + if fields == nil { + return + } + + for _, field = range fields.List { + for _, name = range field.Names { + used[name.Name] = "parameter" + } + } +} + +func typeName(value types.Type, packagePath string) string { + return types.TypeString(value, func(pkg *types.Package) string { + if pkg == nil { + return "" + } + if pkg.Path() == packagePath { + return "" + } + + return pkg.Name() + }) +} + +func uniqueName(base, kind string, used map[string]string) string { + var candidate string + var index int + var found bool + + _, found = used[base] + if !found { + return base + } + + candidate = base + "Value" + for index = 2; ; index++ { + _, found = used[candidate] + if !found { + return candidate + } + candidate = fmt.Sprintf("%sValue%d", base, index) + } +} + +func (r *rewriter) add(identifier *ast.Ident) { + var object types.Object + var base string + var rendered string + var name string + var existing string + var found bool + + if identifier == nil || identifier.Name == "_" { + return + } + + object = r.info.Defs[identifier] + if object == nil { + return + } + if _, found = r.names[object]; found { + return + } + + base = identifier.Name + rendered = typeName(object.Type(), r.packagePath) + if strings.Contains(rendered, "impl.messageState") { + rendered = "messageState" + } + existing, found = r.byBase[base] + if found && r.used[existing] == rendered { + r.names[object] = existing + return + } + + name = uniqueName(base, rendered, r.used) + r.names[object] = name + r.used[name] = rendered + if !found { + r.byBase[base] = name + } + r.locals = append(r.locals, local{Name: name, Type: rendered, Err: base == "err"}) +} + +func (r *rewriter) collect(node ast.Node) bool { + var assignment *ast.AssignStmt + var rangeStatement *ast.RangeStmt + var expression ast.Expr + var identifier *ast.Ident + var nested bool + + if node == nil { + return false + } + + _, nested = node.(*ast.FuncLit) + if nested { + return false + } + + assignment, _ = node.(*ast.AssignStmt) + if assignment != nil && assignment.Tok == token.DEFINE { + for _, expression = range assignment.Lhs { + identifier, _ = expression.(*ast.Ident) + r.add(identifier) + } + assignment.Tok = token.ASSIGN + r.changed = true + } + + rangeStatement, _ = node.(*ast.RangeStmt) + if rangeStatement != nil && rangeStatement.Tok == token.DEFINE { + identifier, _ = rangeStatement.Key.(*ast.Ident) + r.add(identifier) + identifier, _ = rangeStatement.Value.(*ast.Ident) + r.add(identifier) + rangeStatement.Tok = token.ASSIGN + r.changed = true + } + + return true +} + +func (r *rewriter) rename(node ast.Node) bool { + var identifier *ast.Ident + var object types.Object + var name string + var found bool + + if node == nil { + return false + } + + identifier, _ = node.(*ast.Ident) + if identifier == nil { + return true + } + + object = r.info.ObjectOf(identifier) + name, found = r.names[object] + if found { + identifier.Name = name + } + + return true +} + +func localSpecs(locals []local) []ast.Spec { + var specs []ast.Spec + var current local + var expression ast.Expr + + var err error + + sort.SliceStable(locals, func(left, right int) bool { + return !locals[left].Err && locals[right].Err + }) + + for _, current = range locals { + expression, err = parser.ParseExpr(current.Type) + if err != nil { + panic(err) + } + specs = append(specs, &ast.ValueSpec{Names: []*ast.Ident{ast.NewIdent(current.Name)}, Type: expression}) + } + + return specs +} + +func normalizeTypeBuilder(body *ast.BlockStmt) { + var index int + var first *ast.AssignStmt + var second *ast.AssignStmt + var identifier *ast.Ident + var selector *ast.SelectorExpr + var source *ast.Ident + + for index = 0; index+1 < len(body.List); index++ { + first, _ = body.List[index].(*ast.AssignStmt) + second, _ = body.List[index+1].(*ast.AssignStmt) + if first == nil || second == nil || first.Tok != token.DEFINE || len(first.Lhs) != 1 || len(first.Rhs) != 1 || len(second.Lhs) != 1 || len(second.Rhs) != 1 { + continue + } + + identifier, _ = first.Lhs[0].(*ast.Ident) + selector, _ = second.Rhs[0].(*ast.SelectorExpr) + if identifier == nil || identifier.Name != "out" || selector == nil || selector.Sel.Name != "File" { + continue + } + source, _ = selector.X.(*ast.Ident) + if source == nil || source.Name != identifier.Name { + continue + } + + first.Lhs = second.Lhs + first.Rhs[0] = &ast.SelectorExpr{X: first.Rhs[0], Sel: ast.NewIdent("File")} + body.List = append(body.List[:index+1], body.List[index+2:]...) + return + } +} + +func rewriteBody(receiver *ast.FieldList, function *ast.FuncType, body *ast.BlockStmt, info *types.Info, packagePath string) { + var current rewriter + var declaration *ast.DeclStmt + + if body == nil { + return + } + + current = rewriter{info: info, packagePath: packagePath, names: make(map[types.Object]string), used: make(map[string]string), byBase: make(map[string]string)} + fieldNames(receiver, current.used) + fieldNames(function.Params, current.used) + fieldNames(function.Results, current.used) + normalizeTypeBuilder(body) + ast.Inspect(body, current.collect) + if !current.changed { + return + } + + ast.Inspect(body, current.rename) + declaration = &ast.DeclStmt{Decl: &ast.GenDecl{Tok: token.VAR, Specs: localSpecs(current.locals)}} + body.List = append([]ast.Stmt{declaration}, body.List...) +} + +func rewriteFile(path string, file *ast.File, info *types.Info, files *token.FileSet, packagePath string) error { + var declaration ast.Decl + var function *ast.FuncDecl + var output *os.File + + var err error + + for _, declaration = range file.Decls { + function, _ = declaration.(*ast.FuncDecl) + if function == nil || function.Body == nil { + continue + } + + rewriteBody(function.Recv, function.Type, function.Body, info, packagePath) + } + + output, err = os.Create(path) + if err != nil { + return err + } + defer output.Close() + + return format.Node(output, files, file) +} + +func run() error { + var config packages.Config + var loaded []*packages.Package + var pkg *packages.Package + var index int + var path string + + var err error + + config.Mode = packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedSyntax | + packages.NeedTypes | packages.NeedTypesInfo | packages.NeedImports | packages.NeedDeps + loaded, err = packages.Load(&config, "./rpc/gen/mininaru/v1") + if err != nil { + return err + } + if packages.PrintErrors(loaded) != 0 || len(loaded) != 1 { + return fmt.Errorf("load generated protobuf package") + } + + pkg = loaded[0] + for index, path = range pkg.CompiledGoFiles { + if !strings.HasSuffix(path, ".pb.go") || !strings.HasPrefix(path, filepath.Clean("rpc")+string(filepath.Separator)) && !filepath.IsAbs(path) { + continue + } + err = rewriteFile(path, pkg.Syntax[index], pkg.TypesInfo, pkg.Fset, pkg.Types.Path()) + if err != nil { + return err + } + } + + return nil +} + +func main() { + var err error + + err = run() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/util/database.go b/util/database.go index bd383f9..1033e6c 100644 --- a/util/database.go +++ b/util/database.go @@ -143,9 +143,6 @@ func migrations(db *sql.DB) error { return nil } -// ensureTokenUsageCachedTokens repairs databases that recorded migration 0012 -// before cached_tokens was added to that migration file. Migration files are -// immutable once shipped, so those databases otherwise never see the column. func ensureTokenUsageCachedTokens(db *sql.DB) error { var rows *sql.Rows var name string diff --git a/util/migrations/0014_rpc_clients.sql b/util/migrations/0014_rpc_clients.sql new file mode 100644 index 0000000..c396e70 --- /dev/null +++ b/util/migrations/0014_rpc_clients.sql @@ -0,0 +1,24 @@ +CREATE TABLE rpc_clients ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + fingerprint TEXT NOT NULL UNIQUE, + public_key BLOB NOT NULL, + certificate_serial TEXT NOT NULL UNIQUE, + paired_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL DEFAULT 0, + revoked_at INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE rpc_pairings ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + fingerprint TEXT NOT NULL, + public_key BLOB NOT NULL, + certificate_pem BLOB NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + status TEXT NOT NULL +); + +CREATE INDEX rpc_pairings_expires_at ON rpc_pairings(expires_at);