initial commit
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
|
||||
// self-describing and a module never needs to import store internals (the
|
||||
// boundary is the point). The store adapter maps store.* ⇔ these 1:1.
|
||||
|
||||
// Fact — one observation. Ts is valid-time (true-as-of), as in store.
|
||||
type Fact struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Kind string `json:"kind"` // "self" | "env" | "config"
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"` // raw json if structured
|
||||
Source string `json:"source"` // tap:*|infer:*|poll:*|ambient|promote|feedback
|
||||
Confidence float64 `json:"confidence"`
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
// Bucket — presence hysteresis state: "present" | "away".
|
||||
type Bucket string
|
||||
|
||||
const (
|
||||
Present Bucket = "present"
|
||||
Away Bucket = "away"
|
||||
)
|
||||
|
||||
// Nudge — one proactive send + its outcome, for the monitoring read path.
|
||||
type Nudge struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Rule string `json:"rule"`
|
||||
Channel string `json:"channel"`
|
||||
Message string `json:"message"`
|
||||
Outcome string `json:"outcome"` // pending|acted|snoozed|ignored
|
||||
OutcomeTs *int64 `json:"outcome_ts,omitempty"`
|
||||
}
|
||||
|
||||
// Note — a recall/preference item; ranked by embedding cosine on query.
|
||||
// Score is set by QueryNotes (0 on the write path).
|
||||
type Note struct {
|
||||
ID int64 `json:"id"`
|
||||
Ts time.Time `json:"ts"`
|
||||
Text string `json:"text"`
|
||||
Source string `json:"source"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
// Reminder — user-stated future intent; fires once.
|
||||
type Reminder struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedTs time.Time `json:"created_ts"`
|
||||
FireTs time.Time `json:"fire_ts"`
|
||||
Payload string `json:"payload"`
|
||||
Status string `json:"status"` // pending|fired|cancelled
|
||||
}
|
||||
|
||||
// Presence — the read the phraser / delivery modules need to decide channel
|
||||
// routing and tone. presence = reachability, NOT wakefulness (spec). Loop
|
||||
// reads probes + computes score itself; modules get the resolved snapshot.
|
||||
type Presence struct {
|
||||
Bucket Bucket `json:"bucket"`
|
||||
Score float64 `json:"score"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// WriteFactReq — the only state mutation a capture/tool module performs.
|
||||
// Confidence is 1.0 for taps, (0,1) for inferences; the store enforces range.
|
||||
// Source is provenance — the server-side source-scope seam (auth layer) will
|
||||
// refuse a module writing under a source it doesn't own ("compromised poller
|
||||
// can't forge a trigger"). Today the floor permits any local caller.
|
||||
type WriteFactReq struct {
|
||||
Ts time.Time `json:"ts"`
|
||||
Kind string `json:"kind"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Source string `json:"source"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
// idReq — methods keyed by a single id.
|
||||
type idReq struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
// markReminderReq — pending→fired|cancelled.
|
||||
type markReminderReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// resolveNudgeReq — pending→acted|snoozed|ignored, once.
|
||||
type resolveNudgeReq struct {
|
||||
ID int64 `json:"id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// keyReq / keySourceReq / sinceReq / outcomesReq — read param shapes.
|
||||
type keyReq struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
type keySourceReq struct {
|
||||
Key string `json:"key"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
type sinceReq struct {
|
||||
Key string `json:"key"`
|
||||
Now time.Time `json:"now"`
|
||||
}
|
||||
type outcomesReq struct {
|
||||
Rule string `json:"rule"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
type nReq struct {
|
||||
N int `json:"n"`
|
||||
}
|
||||
type writeNoteReq struct {
|
||||
Ts time.Time `json:"ts"`
|
||||
Text string `json:"text"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
type queryNotesReq struct {
|
||||
Embedding []float32 `json:"embedding"`
|
||||
K int `json:"k"`
|
||||
}
|
||||
type createReminderReq struct {
|
||||
Fire time.Time `json:"fire"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
type recordNudgeReq struct {
|
||||
Rule string `json:"rule"`
|
||||
Channel string `json:"channel"`
|
||||
Message string `json:"message"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
|
||||
// idResp / sinceResp — small scalar return wrappers.
|
||||
type idResp struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
type sinceResp struct {
|
||||
Dur time.Duration `json:"dur"`
|
||||
}
|
||||
|
||||
// Tool — an act allowlist entry as core exposes it. Status 'proposed' is an
|
||||
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
|
||||
type Tool struct {
|
||||
Name string `json:"name"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Destructive bool `json:"destructive"`
|
||||
Status string `json:"status"`
|
||||
Utterance string `json:"utterance"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
type proposeToolReq struct {
|
||||
Name string `json:"name"`
|
||||
Utterance string `json:"utterance"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
type proposeToolResp struct {
|
||||
Proposed bool `json:"proposed"`
|
||||
}
|
||||
type enableToolReq struct {
|
||||
Name string `json:"name"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Destructive bool `json:"destructive"`
|
||||
Ts time.Time `json:"ts"`
|
||||
}
|
||||
type lookupToolReq struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
type listToolsReq struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
type listToolsResp struct {
|
||||
Tools []Tool `json:"tools"`
|
||||
}
|
||||
|
||||
// CoreAPI — what core exposes to modules. One Go interface, satisfied by:
|
||||
// - the in-process store adapter (server.go storeAPI) — used by the daemon
|
||||
// for modules that live in-process for now (router, delivery) and by tests,
|
||||
// - the socket-backed server's dispatcher (which delegates to a CoreAPI),
|
||||
// - the client proxy (client.go) — same interface, over the wire.
|
||||
//
|
||||
// So a module imports ipc, holds a CoreAPI, and is agnostic to whether it's
|
||||
// been wired in-process (tests / daemon-embedded) or socketed (full topology).
|
||||
// That swappability is the seam the auth layer will insert into without
|
||||
// touching the module code.
|
||||
type CoreAPI interface {
|
||||
WriteFact(ctx context.Context, req WriteFactReq) (int64, error)
|
||||
LatestFact(ctx context.Context, key string) (Fact, error)
|
||||
LatestFactBySource(ctx context.Context, key, source string) (Fact, error)
|
||||
Since(ctx context.Context, key string, now time.Time) (time.Duration, error)
|
||||
Presence(ctx context.Context) (Presence, error)
|
||||
CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error)
|
||||
MarkReminder(ctx context.Context, id int64, status string) error
|
||||
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
|
||||
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
|
||||
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
|
||||
RecentFacts(ctx context.Context, n int) ([]Fact, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]Nudge, error)
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]Note, error)
|
||||
|
||||
// ProposeTool drafts an inert 'proposed' tool scaffold (maven-callable);
|
||||
// returns whether a new proposal was written. EnableTool fills cmd +
|
||||
// destructive and flips to 'enabled' — the human-only "enable" act, gated
|
||||
// at AuthStepUp (see auth/policy.go). LookupTool/ListTools read them.
|
||||
ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error)
|
||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error
|
||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||
}
|
||||
|
||||
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for
|
||||
// wire round-tripping via errors.Is).
|
||||
var ErrToolNotFound = errors.New("ipc: tool not found")
|
||||
|
||||
// callerKey — context key for the authenticated caller. Server sets it from
|
||||
// SO_PEERCRED before dispatch; in-process callers omit it (the adapter treats
|
||||
// a missing Caller as "trusted same-process", the equivalent of the socket's
|
||||
// 0600 floor).
|
||||
type callerKey struct{}
|
||||
|
||||
// Caller — the peer identity as core sees it. Uid/Pid come from SO_PEERCRED
|
||||
// on Linux; the future auth layer maps Uid + module enrollment → authority.
|
||||
// Today only Uid is populated and used for a same-user check.
|
||||
type Caller struct {
|
||||
Uid int32
|
||||
Pid int32
|
||||
}
|
||||
|
||||
// WithCaller returns ctx annotated with c. Server-side use only.
|
||||
func WithCaller(ctx context.Context, c Caller) context.Context {
|
||||
return context.WithValue(ctx, callerKey{}, c)
|
||||
}
|
||||
|
||||
// CallerFrom retrieves the Caller, or ok=false if absent (in-process path).
|
||||
func CallerFrom(ctx context.Context) (Caller, bool) {
|
||||
c, ok := ctx.Value(callerKey{}).(Caller)
|
||||
return c, ok
|
||||
}
|
||||
|
||||
// Sentinel errors. Mirror store's 1:1 so module code reads the same whether
|
||||
// in-process or over the wire. The store adapter translates store.* → these.
|
||||
var (
|
||||
ErrNoFact = errors.New("ipc: no fact for key")
|
||||
ErrConfidence = errors.New("ipc: confidence must be in (0.0, 1.0]")
|
||||
ErrVoidsMissing = errors.New("ipc: voids_id does not reference an existing fact")
|
||||
ErrNudgeNotFound = errors.New("ipc: nudge not found")
|
||||
ErrNudgeOutcome = errors.New("ipc: nudge already resolved")
|
||||
ErrReminderNotFound = errors.New("ipc: reminder not found")
|
||||
ErrReminderState = errors.New("ipc: reminder not in a mutable state")
|
||||
ErrUnknownMethod = errors.New("ipc: unknown method")
|
||||
ErrBadParams = errors.New("ipc: bad params")
|
||||
// ErrForbidden — the caller's authority doesn't cover this call. The
|
||||
// auth layer's only wire-exported verdict: surface caps the layer, or a
|
||||
// write was out-of-scope, or step-up was required but not asserted. The
|
||||
// text ErrForbidden carries is derived during dispatch (from auth.ErrForbidden
|
||||
// via fmt.Errorf %w wrapping); the wire carries codeForbidden.
|
||||
ErrForbidden = errors.New("ipc: forbidden")
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — the module side of the boundary. Wraps a unix-socket connection
|
||||
// and satisfies CoreAPI, so a module imports ipc, holds a CoreAPI, and is
|
||||
// agnostic to whether it's been wired in-process (tests / daemon-embedded)
|
||||
// or over this socket (full topology). The swappability is the seam auth
|
||||
// will insert into without touching module code.
|
||||
//
|
||||
// One Client ⇒ one conn ⇒ one concurrent request at a time. A module that
|
||||
// wants parallel requests opens one Client per goroutine; the store is the
|
||||
// bottleneck anyway (single writer), so pipelining buys nothing here and a
|
||||
// per-Client lock keeps frame interleaving impossible by construction.
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
// owns its Client lifecycle; Close on shutdown.
|
||||
func Dial(path string) (*Client, error) {
|
||||
c, err := net.Dial("unix", path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc: dial %s: %w", path, err)
|
||||
}
|
||||
return &Client{conn: c}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error { return c.conn.Close() }
|
||||
|
||||
// call — the single request/response engine. Serialized by c.mu so a frame
|
||||
// and its reply always pair up; no interleaving to disambiguate. A wire
|
||||
// RpcError is rehydrated into the matching package sentinel (errors.Is works
|
||||
// the same as the in-process path — the boundary is transparent to callers).
|
||||
func (c *Client) call(ctx context.Context, m Method, params, result any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Honor ctx cancellation by closing the conn — a half-sent frame would
|
||||
// desync the stream; tearing down is the clean recovery. A fresh Dial
|
||||
// is the module's responsibility on the next call (modules are long-lived
|
||||
// processes; a dropped conn is recoverable, not fatal).
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = c.conn.Close()
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
var raw json.RawMessage
|
||||
if params != nil {
|
||||
b, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc: marshal params: %w", err)
|
||||
}
|
||||
raw = b
|
||||
}
|
||||
if err := writeFrame(c.conn, Request{Method: m, Params: raw}); err != nil {
|
||||
return err
|
||||
}
|
||||
var resp Response
|
||||
if err := readFrame(c.conn, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return hydrate(resp.Error)
|
||||
}
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
// "null" body into a pointer is valid (sets the zero value); marshal a
|
||||
// RawMessage directly to avoid extra encode/decode churn.
|
||||
return json.Unmarshal(resp.Result, result)
|
||||
}
|
||||
|
||||
// hydrate rehydrates a wire RpcError into the matching package sentinel. The
|
||||
// code↔sentinel table is the only place the wire "knows" about errors; keep it
|
||||
// in sync with codeOf in wire.go.
|
||||
func hydrate(e *RpcError) error {
|
||||
switch e.Code {
|
||||
case codeNoFact:
|
||||
return fmt.Errorf("%w: %s", ErrNoFact, e.Message)
|
||||
case codeConfidence:
|
||||
return fmt.Errorf("%w: %s", ErrConfidence, e.Message)
|
||||
case codeVoidsMissing:
|
||||
return fmt.Errorf("%w: %s", ErrVoidsMissing, e.Message)
|
||||
case codeNudgeNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrNudgeNotFound, e.Message)
|
||||
case codeNudgeOutcome:
|
||||
return fmt.Errorf("%w: %s", ErrNudgeOutcome, e.Message)
|
||||
case codeReminderMissing:
|
||||
return fmt.Errorf("%w: %s", ErrReminderNotFound, e.Message)
|
||||
case codeReminderState:
|
||||
return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
|
||||
case codeToolNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
|
||||
case codeUnknownMethod:
|
||||
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
|
||||
case codeBadParams:
|
||||
return fmt.Errorf("%w: %s", ErrBadParams, e.Message)
|
||||
case codeForbidden:
|
||||
return fmt.Errorf("%w: %s", ErrForbidden, e.Message)
|
||||
default:
|
||||
return errors.New(e.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// CoreAPI implementation on *Client. Each method is a thin call() shim; the
|
||||
// shape mirrors the CoreAPI interface 1:1 so the embedded-doc intent (module
|
||||
// holds a CoreAPI, transport-agnostic) reads straight off the signatures.
|
||||
|
||||
func (c *Client) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
var r idResp
|
||||
if err := c.call(ctx, MethodWriteFact, req, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
var f Fact
|
||||
if err := c.call(ctx, MethodLatestFact, keyReq{Key: key}, &f); err != nil {
|
||||
return Fact{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (c *Client) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
var f Fact
|
||||
if err := c.call(ctx, MethodLatestFactBySource, keySourceReq{Key: key, Source: source}, &f); err != nil {
|
||||
return Fact{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (c *Client) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
var r sinceResp
|
||||
if err := c.call(ctx, MethodSince, sinceReq{Key: key, Now: now}, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.Dur, nil
|
||||
}
|
||||
|
||||
func (c *Client) Presence(ctx context.Context) (Presence, error) {
|
||||
var p Presence
|
||||
if err := c.call(ctx, MethodPresence, nil, &p); err != nil {
|
||||
return Presence{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Client) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
|
||||
var r idResp
|
||||
if err := c.call(ctx, MethodCreateReminder, createReminderReq{Fire: fire, Payload: payload}, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
var r idResp
|
||||
if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return c.call(ctx, MethodResolveNudge, resolveNudgeReq{ID: id, Outcome: outcome, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
var out []string
|
||||
if err := c.call(ctx, MethodRecentOutcomes, outcomesReq{Rule: rule, N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
var out []Fact
|
||||
if err := c.call(ctx, MethodRecentFacts, nReq{N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
var out []Nudge
|
||||
if err := c.call(ctx, MethodRecentNudges, nReq{N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
var r idResp
|
||||
if err := c.call(ctx, MethodWriteNote, writeNoteReq{Ts: ts, Text: text, Embedding: embedding, Source: source}, &r); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return r.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
var out []Note
|
||||
if err := c.call(ctx, MethodQueryNotes, queryNotesReq{Embedding: embedding, K: k}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
var out []Note
|
||||
if err := c.call(ctx, MethodRecentNotes, nReq{N: n}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
||||
var r proposeToolResp
|
||||
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Utterance: utterance, Ts: ts}, &r); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return r.Proposed, nil
|
||||
}
|
||||
|
||||
func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
||||
return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Cmd: cmd, Destructive: destructive, Ts: ts}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
var t Tool
|
||||
if err := c.call(ctx, MethodLookupTool, lookupToolReq{Name: name}, &t); err != nil {
|
||||
return Tool{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
var r listToolsResp
|
||||
if err := c.call(ctx, MethodListTools, listToolsReq{Status: status}, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.Tools, nil
|
||||
}
|
||||
|
||||
// Compile-time check: *Client satisfies CoreAPI.
|
||||
var _ CoreAPI = (*Client)(nil)
|
||||
@@ -0,0 +1,86 @@
|
||||
// Package ipc is maven's core↔module boundary.
|
||||
//
|
||||
// Core = the only key-holder: the daemon process holds the unlocked sqlcipher
|
||||
// db + the trigger loop. Modules (stt/tts, router/classifier, tool executors,
|
||||
// delivery) are separate processes — restart-free, key-free, fail-independent.
|
||||
// "a crashing tts can't read the key page" only holds if there IS a page
|
||||
// boundary between core and modules; this package IS that boundary.
|
||||
//
|
||||
// Transport: unix domain socket, local-only. The socket's filesystem perms
|
||||
// (0700 dir, 0600 socket) are the current auth floor — "same unix user" —
|
||||
// carrying the same instinct as wg-floor at the network radius. The full
|
||||
// 4-layer auth cascade (wg/mTLS/passkey/step-up) is a later module; IPC
|
||||
// threads a Caller (uid/pid via SO_PEERCRED) so the auth layer can scope
|
||||
// module authority without restructuring the wire.
|
||||
//
|
||||
// Core mediates, never hands back a db handle. The methods here are the only
|
||||
// state operations a module can perform: write a fact (provenance-scoped by
|
||||
// source), read a fact / presence, create/complete reminders, record/resolve
|
||||
// nudges. Anything needing raw db access lives in core and is unreachable.
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// maxFrame — 4 MiB. a single fact/reminder/nudge is tiny; this is a safety cap
|
||||
// against a confused peer sending a terabyte of length prefix, not a real
|
||||
// operational limit. away-channel bodies are minimal by spec.
|
||||
const maxFrame = 4 << 20
|
||||
|
||||
// ErrFrameTooLarge — a frame exceeded maxFrame; the conn is now desynchronized
|
||||
// (we read the length but not the body), so the caller must close it.
|
||||
var ErrFrameTooLarge = errors.New("ipc: frame too large")
|
||||
|
||||
// writeFrame encodes v as JSON and frames it as a 4-byte big-endian length
|
||||
// prefix + body. length-prefixed JSON (not a tighter binary schema) is the
|
||||
// deferred-but-picked wire format: debuggable with `socat`/`nc`, trivial to
|
||||
// evolve while the protocol settles, and at single-user local scale the
|
||||
// encode cost is invisible next to a db round-trip.
|
||||
func writeFrame(w io.Writer, v any) error {
|
||||
body, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ipc: marshal frame: %w", err)
|
||||
}
|
||||
if len(body) > maxFrame {
|
||||
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, len(body))
|
||||
}
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return fmt.Errorf("ipc: write frame header: %w", err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return fmt.Errorf("ipc: write frame body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readFrame reads one length-prefixed frame into v. A zero-length frame is
|
||||
// legal JSON (e.g. `null`/`{}` encode to a few bytes, never zero) — we don't
|
||||
// treat it as EOF; only an EOF on the header read does.
|
||||
func readFrame(r io.Reader, v any) error {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return io.EOF
|
||||
}
|
||||
return fmt.Errorf("ipc: read frame header: %w", err)
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
if n > maxFrame {
|
||||
return fmt.Errorf("%w: %d bytes", ErrFrameTooLarge, n)
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return fmt.Errorf("ipc: read frame body: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(buf, v); err != nil {
|
||||
return fmt.Errorf("ipc: unmarshal frame: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// tmpSocket — a socket path under a 0700 temp dir, unique per test.
|
||||
func tmpSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
return filepath.Join(dir, "maven.sock")
|
||||
}
|
||||
|
||||
// newServerWithStore spins a real store + Server + Client so the boundary
|
||||
// is exercised exactly as the daemon wires it. Returns the api (for direct
|
||||
// in-process expectations) and a client going through the socket.
|
||||
func newServerWithStore(t *testing.T) (CoreAPI, *Server, *Client, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
s, err := store.Open(context.Background(), filepath.Join(dir, "maven.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
api := NewStoreAPI(s)
|
||||
srv, err := Listen(tmpSocket(t), api)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
_ = srv.Serve()
|
||||
close(done)
|
||||
}()
|
||||
t.Cleanup(func() {
|
||||
_ = srv.Close()
|
||||
<-done
|
||||
})
|
||||
cli, err := Dial(srv.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cli.Close() })
|
||||
return api, srv, cli, s
|
||||
}
|
||||
|
||||
// TestFrame_Roundtrip — JSON over a length prefix survives the loop, and the
|
||||
// prefix itself encodes the length exactly. The framing is the only thing
|
||||
// keeping a module's request paired with core's reply; it's worth a direct test.
|
||||
func TestFrame_Roundtrip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
type payload struct {
|
||||
Msg string `json:"m"`
|
||||
N int `json:"n"`
|
||||
}
|
||||
want := payload{Msg: "hello", N: 42}
|
||||
if err := writeFrame(&buf, want); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
// header length must equal the JSON body length that follows.
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(&buf, hdr[:]); err != nil {
|
||||
t.Fatalf("read hdr: %v", err)
|
||||
}
|
||||
bodyLen := binary.BigEndian.Uint32(hdr[:])
|
||||
if int(bodyLen) != buf.Len() {
|
||||
t.Fatalf("prefix length %d != body %d", bodyLen, buf.Len())
|
||||
}
|
||||
// readFrame consumes header+body together; recombine so it sees a whole frame.
|
||||
full := append(hdr[:], buf.Bytes()...)
|
||||
var got payload
|
||||
if err := readFrame(bytes.NewReader(full), &got); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("roundtrip mismatch: got %+v want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrame_TooLarge(t *testing.T) {
|
||||
// Encode-side guard refuses to ship anything bigger than maxFrame; the
|
||||
// socket never sees it. Defense against a confused peer, not a real path.
|
||||
big := make([]byte, maxFrame+1)
|
||||
if err := writeFrame(io.Discard, big); !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("writeFrame: got %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
// Decode-side guard refuses a header claiming a too-large body; the conn
|
||||
// is now desynced (length read, body not), but readFrame doesn't have to
|
||||
// recover — the caller closes it.
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], maxFrame+1)
|
||||
if err := readFrame(bytes.NewReader(hdr[:]), nil); !errors.Is(err, ErrFrameTooLarge) {
|
||||
t.Fatalf("readFrame: got %v, want ErrFrameTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSocket_Perms — the auth floor. 0600 ⇒ only the same unix user can
|
||||
// connect. If this regresses to world-readable, every user on the box is a
|
||||
// module; that's the entire auth model today, so assert it.
|
||||
func TestSocket_Perms(t *testing.T) {
|
||||
_, srv, _, _ := newServerWithStore(t)
|
||||
fi, err := os.Stat(srv.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat socket: %v", err)
|
||||
}
|
||||
mode := fi.Mode().Perm()
|
||||
if mode != 0o600 {
|
||||
t.Fatalf("socket perm = %#o, want 0600", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreAPI_Direct — the in-process adapter path (no socket) maps store
|
||||
// sentinels to ipc sentinels. The boundary's contract is that error identity
|
||||
// is the same on both sides; this pins it for the daemon-embedded modules
|
||||
// (router, delivery today) that never go over the wire.
|
||||
func TestStoreAPI_Direct(t *testing.T) {
|
||||
api, _, _, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// missing key ⇒ ErrNoFact
|
||||
if _, err := api.LatestFact(ctx, "nope"); !errors.Is(err, ErrNoFact) {
|
||||
t.Fatalf("LatestFact missing: got %v, want ErrNoFact", err)
|
||||
}
|
||||
// bad confidence ⇒ ErrConfidence
|
||||
if _, err := api.WriteFact(ctx, WriteFactReq{
|
||||
Ts: time.Now(), Kind: "self", Key: "water", Value: "1", Source: "tap:water", Confidence: 0,
|
||||
}); !errors.Is(err, ErrConfidence) {
|
||||
t.Fatalf("WriteFact conf=0: got %v, want ErrConfidence", err)
|
||||
}
|
||||
// since on missing key ⇒ ErrNoFact
|
||||
if _, err := api.Since(ctx, "nope", time.Now()); !errors.Is(err, ErrNoFact) {
|
||||
t.Fatalf("Since missing: got %v, want ErrNoFact", err)
|
||||
}
|
||||
// reminder idempotency: invalid status ⇒ ErrReminderState
|
||||
if err := api.MarkReminder(ctx, 99999, "weird"); !errors.Is(err, ErrReminderState) {
|
||||
t.Fatalf("MarkReminder weird: got %v, want ErrReminderState", err)
|
||||
}
|
||||
// resolve nonexistent nudge ⇒ ErrNudgeNotFound
|
||||
if err := api.ResolveNudge(ctx, 99999, "acted", time.Now()); !errors.Is(err, ErrNudgeNotFound) {
|
||||
t.Fatalf("ResolveNudge none: got %v, want ErrNudgeNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClient_E2E — full socket round trip against a real store. Drives every
|
||||
// method end-to-end and asserts sentinel identity survives the wire. This is
|
||||
// the test that catches the boundary bugs: param shape mismatch, sentinel
|
||||
// code drift, dto mapping, framing interleaving.
|
||||
func TestClient_E2E(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||
|
||||
// write a tap (self, confidence 1.0) and read it back.
|
||||
id, err := cli.WriteFact(ctx, WriteFactReq{
|
||||
Ts: now, Kind: "self", Key: "water", Value: "1", Source: "tap:water", Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("WriteFact returned id %d", id)
|
||||
}
|
||||
f, err := cli.LatestFact(ctx, "water")
|
||||
if err != nil {
|
||||
t.Fatalf("LatestFact: %v", err)
|
||||
}
|
||||
if f.Key != "water" || f.Value != "1" || f.Source != "tap:water" || f.Confidence != 1.0 {
|
||||
t.Fatalf("LatestFact mismatch: %+v", f)
|
||||
}
|
||||
if !f.Ts.Equal(now) {
|
||||
t.Fatalf("Ts roundtrip: got %v want %v", f.Ts, now)
|
||||
}
|
||||
|
||||
// provenance scope: a foreign source doesn't see the tap value.
|
||||
if _, err := cli.LatestFactBySource(ctx, "water", "poll:evil"); !errors.Is(err, ErrNoFact) {
|
||||
t.Fatalf("LatestFactBySource foreign: got %v, want ErrNoFact", err)
|
||||
}
|
||||
if _, err := cli.LatestFactBySource(ctx, "water", "tap:water"); err != nil {
|
||||
t.Fatalf("LatestFactBySource own: %v", err)
|
||||
}
|
||||
|
||||
// since: ~0 elapsed since "now".
|
||||
d, err := cli.Since(ctx, "water", now.Add(time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("Since: %v", err)
|
||||
}
|
||||
if d != time.Second {
|
||||
t.Fatalf("Since dur = %v, want 1s", d)
|
||||
}
|
||||
// since missing ⇒ ErrNoFact over the wire.
|
||||
if _, err := cli.Since(ctx, "nope", now); !errors.Is(err, ErrNoFact) {
|
||||
t.Fatalf("Since missing: got %v, want ErrNoFact", err)
|
||||
}
|
||||
|
||||
// presence cold-start ⇒ away, score 0.
|
||||
pres, err := cli.Presence(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Presence: %v", err)
|
||||
}
|
||||
if pres.Bucket != Away || pres.Score != 0 {
|
||||
t.Fatalf("Presence cold-start = %+v, want away/0", pres)
|
||||
}
|
||||
|
||||
// reminder lifecycle: create → mark fired → re-mark ⇒ ErrReminderState.
|
||||
rid, err := cli.CreateReminder(ctx, now.Add(time.Hour), `{"text":"wake me 7"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if err := cli.MarkReminder(ctx, rid, "fired"); err != nil {
|
||||
t.Fatalf("MarkReminder fired: %v", err)
|
||||
}
|
||||
if err := cli.MarkReminder(ctx, rid, "fired"); !errors.Is(err, ErrReminderState) {
|
||||
t.Fatalf("MarkReminder twice: got %v, want ErrReminderState", err)
|
||||
}
|
||||
|
||||
// nudge lifecycle: record → resolve acted → resolve again ⇒ ErrNudgeOutcome.
|
||||
nid, err := cli.RecordNudge(ctx, "water", "voice", "drink", now)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordNudge: %v", err)
|
||||
}
|
||||
if err := cli.ResolveNudge(ctx, nid, "acted", now); err != nil {
|
||||
t.Fatalf("ResolveNudge acted: %v", err)
|
||||
}
|
||||
if err := cli.ResolveNudge(ctx, nid, "ignored", now); !errors.Is(err, ErrNudgeOutcome) {
|
||||
t.Fatalf("ResolveNudge twice: got %v, want ErrNudgeOutcome", err)
|
||||
}
|
||||
|
||||
// feedback loop read: RecentOutcomes returns the resolved outcome.
|
||||
out, err := cli.RecentOutcomes(ctx, "water", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentOutcomes: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0] != "acted" {
|
||||
t.Fatalf("RecentOutcomes = %v, want [acted]", out)
|
||||
}
|
||||
// empty result over the wire is a stable [] not null (server coerces).
|
||||
if got, err := cli.RecentOutcomes(ctx, "never_fired_rule", 5); err != nil || len(got) != 0 {
|
||||
t.Fatalf("RecentOutcomes empty = %v err=%v, want []", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCaller_Peercred — when the client dials, core sees a Caller with the
|
||||
// test process's own uid via SO_PEERCRED. This is the seam auth scopes on;
|
||||
// asserting it's populated today means the future auth layer has its input.
|
||||
func TestCaller_Peercred(t *testing.T) {
|
||||
_, srv, cli, _ := newServerWithStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// round-trip any call; the server annotates ctx with a Caller on accept.
|
||||
if _, err := cli.LatestFact(ctx, "nope"); err != nil && !errors.Is(err, ErrNoFact) {
|
||||
t.Fatalf("LatestFact: %v", err)
|
||||
}
|
||||
|
||||
// introspect the server's view: re-accept a conn manually and read creds.
|
||||
uc, err := dialRaw(srv.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("dialRaw: %v", err)
|
||||
}
|
||||
defer uc.Close()
|
||||
c, ok := peerCaller(uc)
|
||||
if !ok {
|
||||
t.Skip("SO_PEERCRED unavailable on this platform; skipping")
|
||||
}
|
||||
if c.Uid != int32(os.Getuid()) {
|
||||
t.Fatalf("peercred uid = %d, want %d", c.Uid, os.Getuid())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatch_UnknownMethod — an unknown method over the wire comes back as
|
||||
// ErrUnknownMethod, not a panic or a dropped conn. The server must stay up
|
||||
// for the next (legitimate) request on the same conn.
|
||||
func TestDispatch_UnknownMethod(t *testing.T) {
|
||||
_, srv, _, _ := newServerWithStore(t)
|
||||
|
||||
uc, err := dialRaw(srv.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("dialRaw: %v", err)
|
||||
}
|
||||
defer uc.Close()
|
||||
|
||||
// send garbage method on the raw conn, read back its error, then send a
|
||||
// real method on the SAME conn to confirm the server survived.
|
||||
if err := writeFrame(uc, Request{Method: Method("definitely_not_a_method")}); err != nil {
|
||||
t.Fatalf("writeFrame: %v", err)
|
||||
}
|
||||
var resp Response
|
||||
if err := readFrame(uc, &resp); err != nil {
|
||||
t.Fatalf("readFrame: %v", err)
|
||||
}
|
||||
if resp.Error == nil || !errors.Is(hydrate(resp.Error), ErrUnknownMethod) {
|
||||
t.Fatalf("unknown method response = %+v, want ErrUnknownMethod", resp.Error)
|
||||
}
|
||||
// same conn, legit follow-up: prove the goroutine is still alive.
|
||||
if err := writeFrame(uc, Request{Method: MethodLatestFact, Params: mustJSON(keyReq{Key: "nope"})}); err != nil {
|
||||
t.Fatalf("writeFrame follow-up: %v", err)
|
||||
}
|
||||
if err := readFrame(uc, &resp); err != nil {
|
||||
t.Fatalf("readFrame follow-up: %v", err)
|
||||
}
|
||||
if resp.Error == nil || !errors.Is(hydrate(resp.Error), ErrNoFact) {
|
||||
t.Fatalf("follow-up response = %+v, want ErrNoFact", resp.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// dialRaw — a bare unix conn for tests that want to script the wire directly
|
||||
// (send an unknown method, follow up on the same conn, inspect framing).
|
||||
func dialRaw(path string) (net.Conn, error) {
|
||||
return net.Dial("unix", path)
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// storeAPI — adapts *store.Store to CoreAPI. The daemon constructs one of
|
||||
// these inside the core process; the socket Server calls it through the
|
||||
// CoreAPI interface, so over-the-wire and in-process callers behave
|
||||
// identically. The translation here is the only place store sentinels cross
|
||||
// the wire: store.ErrNoFact becomes ipc.ErrNoFact, etc. — keeping the module
|
||||
// view of errors stable regardless of transport.
|
||||
type storeAPI struct {
|
||||
s *store.Store
|
||||
}
|
||||
|
||||
// NewStoreAPI wraps a *store.Store as a CoreAPI. The store is the sqlcipher-
|
||||
// unlocked handle held ONLY in core's address space; this adapter never
|
||||
// returns it to a caller — core mediates.
|
||||
func NewStoreAPI(s *store.Store) CoreAPI { return &storeAPI{s: s} }
|
||||
|
||||
func (a *storeAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
var voids sql.NullInt64
|
||||
if req.VoidsID != nil {
|
||||
voids = sql.NullInt64{Int64: *req.VoidsID, Valid: true}
|
||||
}
|
||||
id, err := a.s.WriteFact(ctx, req.Ts, store.FactKind(req.Kind), req.Key, req.Value, req.Source, req.Confidence, voids)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
f, err := a.s.LatestFact(ctx, key)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
f, err := a.s.LatestFactBySource(ctx, key, source)
|
||||
if err != nil {
|
||||
return Fact{}, mapErr(err)
|
||||
}
|
||||
return toFact(f), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
d, err := a.s.Since(ctx, key, now)
|
||||
return d, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
b, score, upd, err := a.s.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
return Presence{}, fmt.Errorf("ipc: load presence: %w", err)
|
||||
}
|
||||
return Presence{Bucket: Bucket(b), Score: score, Updated: upd}, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) CreateReminder(ctx context.Context, fire time.Time, payload string) (int64, error) {
|
||||
id, err := a.s.CreateReminder(ctx, fire, payload)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return mapErr(a.s.MarkReminder(ctx, id, status))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
id, err := a.s.RecordNudge(ctx, rule, channel, message, ts)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return mapErr(a.s.ResolveNudge(ctx, id, outcome, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
out, err := a.s.RecentOutcomes(ctx, rule, n)
|
||||
return out, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
fs, err := a.s.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Fact, len(fs))
|
||||
for i, f := range fs {
|
||||
out[i] = toFact(f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
ns, err := a.s.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Nudge, len(ns))
|
||||
for i, ng := range ns {
|
||||
out[i] = toNudge(ng)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.s.WriteNote(ctx, ts, text, embedding, source)
|
||||
return id, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
ns, err := a.s.QueryNotes(ctx, embedding, k)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, n := range ns {
|
||||
out[i] = toNote(n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
ns, err := a.s.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Note, len(ns))
|
||||
for i, note := range ns {
|
||||
out[i] = toNote(note)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
||||
ok, err := a.s.ProposeTool(ctx, name, utterance, ts)
|
||||
return ok, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
||||
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, ts))
|
||||
}
|
||||
|
||||
func (a *storeAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
t, err := a.s.LookupTool(ctx, name)
|
||||
if err != nil {
|
||||
return Tool{}, mapErr(err)
|
||||
}
|
||||
return toTool(t), nil
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
ts, err := a.s.ListTools(ctx, status)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]Tool, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = toTool(t)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toTool(t store.Tool) Tool {
|
||||
return Tool{
|
||||
Name: t.Name, Cmd: t.Cmd, Destructive: t.Destructive, Status: t.Status,
|
||||
Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
||||
}
|
||||
}
|
||||
|
||||
func toNote(n store.Note) Note {
|
||||
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
|
||||
}
|
||||
|
||||
func toNudge(n store.Nudge) Nudge {
|
||||
out := Nudge{
|
||||
ID: n.ID, Ts: n.Ts, Rule: n.Rule, Channel: n.Channel,
|
||||
Message: n.Message, Outcome: n.Outcome,
|
||||
}
|
||||
if n.OutcomeTs.Valid {
|
||||
v := n.OutcomeTs.Int64
|
||||
out.OutcomeTs = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toFact(f store.Fact) Fact {
|
||||
out := Fact{
|
||||
ID: f.ID,
|
||||
Ts: f.Ts,
|
||||
Kind: string(f.Kind),
|
||||
Key: f.Key,
|
||||
Value: f.Value,
|
||||
Source: f.Source,
|
||||
Confidence: f.Confidence,
|
||||
}
|
||||
if f.VoidsID.Valid {
|
||||
v := f.VoidsID.Int64
|
||||
out.VoidsID = &v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mapErr — store sentinel ↔ ipc sentinel. An unrecognized store error is
|
||||
// wrapped but not mapped (server-side dispatch surfaces it as codeInternal,
|
||||
// keeping internal text off the wire except to the daemon log).
|
||||
func mapErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNoFact):
|
||||
return ErrNoFact
|
||||
case errors.Is(err, store.ErrConfidence):
|
||||
return ErrConfidence
|
||||
case errors.Is(err, store.ErrVoidsMissing):
|
||||
return ErrVoidsMissing
|
||||
case errors.Is(err, store.ErrNudgeNotFound):
|
||||
return ErrNudgeNotFound
|
||||
case errors.Is(err, store.ErrNudgeOutcome):
|
||||
return ErrNudgeOutcome
|
||||
case errors.Is(err, store.ErrReminderNotFound):
|
||||
return ErrReminderNotFound
|
||||
case errors.Is(err, store.ErrReminderState):
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||
// One Server per daemon process; concurrent connections are handled in their
|
||||
// own goroutine but share the single CoreAPI (and therefore the single store
|
||||
// writer — store is single-connection, SetMaxOpenConns(1), so serialization is
|
||||
// already guaranteed at the db; the Server adds no locking of its own).
|
||||
type Server struct {
|
||||
api CoreAPI
|
||||
path string
|
||||
|
||||
ln net.Listener
|
||||
wg sync.WaitGroup
|
||||
done chan struct{}
|
||||
|
||||
// Check — optional authorization hook. dispatch runs it BEFORE method
|
||||
// dispatch, with the raw params, so the auth layer can make verdicts
|
||||
// that depend on the call's shape (e.g. WriteFact's source). A non-nil
|
||||
// error aborts the call; the wire code is codeForbidden when the error
|
||||
// satisfies errors.Is(ErrForbidden), else codeInternal.
|
||||
//
|
||||
// Nil ⇒ today's auth floor: any same-uid caller (the 0600 socket perms)
|
||||
// is authorized, identical to pre-auth behavior. The daemon sets this to
|
||||
// auth.Gate.Check once the auth layer is constructed; there is no module
|
||||
// change to gain or lose the seam.
|
||||
Check CheckFunc
|
||||
|
||||
// now is injected so tests can drive time; the loop already works in
|
||||
// absolute ts supplied by callers, so this isn't load-bearing for live ops.
|
||||
}
|
||||
|
||||
// CheckFunc — the auth hook signature. Wired by the daemon (auth.Gate.Check
|
||||
// satisfies this); dispatch calls it once per request after param-unmarshal
|
||||
// independence (it gets the raw params, may unmarshal what it needs — ipc
|
||||
// already unmarshals for the typed call separately). Keeping Check on raw
|
||||
// params means ipc doesn't need to know each method's authority shape, and
|
||||
// auth doesn't need to leak implementation into ipc.
|
||||
type CheckFunc func(ctx context.Context, m Method, params json.RawMessage) error
|
||||
|
||||
// Listen creates a Server bound to path. path's parent dir must exist and be
|
||||
// 0700 (we chmod it if we own it); the socket file itself is created 0600 so
|
||||
// only the same unix user can connect — the current "auth floor", same radius
|
||||
// as wg at the network boundary. Removing a stale socket at path first lets
|
||||
// the daemon restart cleanly.
|
||||
func Listen(path string, api CoreAPI) (*Server, error) {
|
||||
_ = os.Remove(path) // stale socket from a crashed daemon; ignore missing
|
||||
if err := os.MkdirAll(parentDir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("ipc: mkdir socket dir: %w", err)
|
||||
}
|
||||
// umask could widen the perms on socket creation; tighten then chmod to
|
||||
// be explicit. 0600 ⇒ read+write by owner only.
|
||||
oldMask := unix.Umask(0o077)
|
||||
ln, err := net.Listen("unix", path)
|
||||
unix.Umask(oldMask)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipc: listen %s: %w", path, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, fmt.Errorf("ipc: chmod socket: %w", err)
|
||||
}
|
||||
return &Server{
|
||||
api: api,
|
||||
path: path,
|
||||
ln: ln,
|
||||
done: make(chan struct{}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Serve accepts connections until the listener closes. Each connection is
|
||||
// served in its own goroutine; a panicking handler or a malformed frame tears
|
||||
// down only that conn, not the server (a misbehaving module can't kill core).
|
||||
func (s *Server) Serve() error {
|
||||
for {
|
||||
c, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.done:
|
||||
return nil // graceful Close
|
||||
default:
|
||||
return fmt.Errorf("ipc: accept: %w", err)
|
||||
}
|
||||
}
|
||||
s.wg.Add(1)
|
||||
go func(c net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer c.Close()
|
||||
s.serveConn(c)
|
||||
}(c)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveConn(c net.Conn) {
|
||||
caller, callerOK := peerCaller(c)
|
||||
ctx := context.Background()
|
||||
if callerOK {
|
||||
ctx = WithCaller(ctx, caller)
|
||||
}
|
||||
for {
|
||||
var req Request
|
||||
if err := readFrame(c, &req); err != nil {
|
||||
return // EOF or malformed ⇒ end this conn; nothing to recover
|
||||
}
|
||||
// redispatch expects the framework's recover so one bad call can't
|
||||
// take the goroutine (and therefore the conn) with it.
|
||||
result, err := s.safeDispatch(ctx, req)
|
||||
resp := Response{}
|
||||
if err != nil {
|
||||
resp.Error = rpcErr(err)
|
||||
} else {
|
||||
resp.Result = result
|
||||
}
|
||||
if err := writeFrame(c, resp); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.RawMessage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("ipc: panic dispatching %s: %v", req.Method, r)
|
||||
}
|
||||
}()
|
||||
return s.dispatch(ctx, req)
|
||||
}
|
||||
|
||||
// dispatch unmarshals params for req.Method and calls the matching CoreAPI
|
||||
// method. Unknown method ⇒ ErrUnknownMethod; a malformed params payload ⇒
|
||||
// ErrBadParams with the underlying text (local, server-side, not shipped to
|
||||
// the module except as a generic message via rpcErr).
|
||||
//
|
||||
// Authorization runs ONCE at the top: if Server.Check is set, we call it with
|
||||
// the raw params before any method-specific unmarshal; auth unmarshals fields
|
||||
// it cares about (WriteFact's source, etc.) itself. A nil Check is the floor
|
||||
// and is invisible at the wire — pre-auth Server behavior is unchanged.
|
||||
func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, error) {
|
||||
if s.Check != nil {
|
||||
if err := s.Check(ctx, req.Method, req.Params); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
switch req.Method {
|
||||
case MethodWriteFact:
|
||||
var p WriteFactReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.api.WriteFact(ctx, p)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodLatestFact:
|
||||
var p keyReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := s.api.LatestFact(ctx, p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(f), nil
|
||||
|
||||
case MethodLatestFactBySource:
|
||||
var p keySourceReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := s.api.LatestFactBySource(ctx, p.Key, p.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(f), nil
|
||||
|
||||
case MethodSince:
|
||||
var p sinceReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := s.api.Since(ctx, p.Key, p.Now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(sinceResp{Dur: d}), nil
|
||||
|
||||
case MethodPresence:
|
||||
pres, err := s.api.Presence(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(pres), nil
|
||||
|
||||
case MethodCreateReminder:
|
||||
var p createReminderReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.api.CreateReminder(ctx, p.Fire, p.Payload)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodMarkReminder:
|
||||
var p markReminderReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := s.api.MarkReminder(ctx, p.ID, p.Status)
|
||||
return marshalResult(nil), err
|
||||
|
||||
case MethodRecordNudge:
|
||||
var p recordNudgeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodResolveNudge:
|
||||
var p resolveNudgeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := s.api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
|
||||
return marshalResult(nil), err
|
||||
|
||||
case MethodRecentOutcomes:
|
||||
var p outcomesReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.RecentOutcomes(ctx, p.Rule, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{} // stable non-null on the wire
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentFacts:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.RecentFacts(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentNudges:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Nudge{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodWriteNote:
|
||||
var p writeNoteReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := s.api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodQueryNotes:
|
||||
var p queryNotesReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.QueryNotes(ctx, p.Embedding, p.K)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentNotes:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.RecentNotes(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodProposeTool:
|
||||
var p proposeToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(proposeToolResp{Proposed: ok}), nil
|
||||
|
||||
case MethodEnableTool:
|
||||
var p enableToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Ts)
|
||||
|
||||
case MethodLookupTool:
|
||||
var p lookupToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.api.LookupTool(ctx, p.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(t), nil
|
||||
|
||||
case MethodListTools:
|
||||
var p listToolsReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.api.ListTools(ctx, p.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Tool{}
|
||||
}
|
||||
return marshalResult(listToolsResp{Tools: out}), nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, v any) error {
|
||||
if len(raw) == 0 {
|
||||
raw = []byte("null")
|
||||
}
|
||||
if err := json.Unmarshal(raw, v); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrBadParams, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalResult(v any) json.RawMessage {
|
||||
if v == nil {
|
||||
return json.RawMessage("null")
|
||||
}
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// Close stops accepting and waits for in-flight connections to drain. The
|
||||
// socket file is removed so a restart can rebind cleanly. Idempotent.
|
||||
func (s *Server) Close() error {
|
||||
select {
|
||||
case <-s.done:
|
||||
return nil
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
err := s.ln.Close()
|
||||
s.wg.Wait()
|
||||
_ = os.Remove(s.path)
|
||||
return err
|
||||
}
|
||||
|
||||
// Path returns the filesystem path of the listening socket.
|
||||
func (s *Server) Path() string { return s.path }
|
||||
|
||||
func parentDir(p string) string {
|
||||
if i := lastIndexByte(p, '/'); i >= 0 {
|
||||
if i == 0 {
|
||||
return "/"
|
||||
}
|
||||
return p[:i]
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
func lastIndexByte(s string, b byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// peerCaller — read SO_PEERCRED off a unix conn to identify the connecting
|
||||
// process. Returns ok=false on a non-unix conn or a platform without
|
||||
// SO_PEERCRED; the caller then proceeds without a Caller (the socket perms
|
||||
// already proved same-user). Linux only today; on other platforms this floors
|
||||
// to "unknown caller" rather than failing — the wire still works.
|
||||
func peerCaller(c net.Conn) (Caller, bool) {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return Caller{}, false
|
||||
}
|
||||
raw, err := uc.SyscallConn()
|
||||
if err != nil {
|
||||
return Caller{}, false
|
||||
}
|
||||
var cred *unix.Ucred
|
||||
ctrlErr := raw.Control(func(fd uintptr) {
|
||||
cred, err = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
})
|
||||
if ctrlErr != nil || err != nil || cred == nil {
|
||||
return Caller{}, false
|
||||
}
|
||||
return Caller{Uid: int32(cred.Uid), Pid: int32(cred.Pid)}, true
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Method — one RPC verb. The set is intentionally small: it mirrors exactly
|
||||
// what a module legitimately needs from core state, and nothing more. Adding
|
||||
// a method is a core-authority change (every method is a new thing a module
|
||||
// can ask for); do it deliberately.
|
||||
type Method string
|
||||
|
||||
const (
|
||||
MethodWriteFact Method = "write_fact"
|
||||
MethodLatestFact Method = "latest_fact"
|
||||
MethodLatestFactBySource Method = "latest_fact_by_source"
|
||||
MethodSince Method = "since"
|
||||
MethodPresence Method = "presence"
|
||||
MethodCreateReminder Method = "create_reminder"
|
||||
MethodMarkReminder Method = "mark_reminder"
|
||||
MethodRecordNudge Method = "record_nudge"
|
||||
MethodResolveNudge Method = "resolve_nudge"
|
||||
MethodRecentOutcomes Method = "recent_outcomes"
|
||||
MethodRecentFacts Method = "recent_facts"
|
||||
MethodRecentNudges Method = "recent_nudges"
|
||||
MethodWriteNote Method = "write_note"
|
||||
MethodQueryNotes Method = "query_notes"
|
||||
MethodRecentNotes Method = "recent_notes"
|
||||
MethodProposeTool Method = "propose_tool"
|
||||
MethodEnableTool Method = "enable_tool"
|
||||
MethodLookupTool Method = "lookup_tool"
|
||||
MethodListTools Method = "list_tools"
|
||||
)
|
||||
|
||||
// Request — one frame from module to core. Params is the JSON-encoded argument
|
||||
// struct for Method (see api.go for the per-method shapes). The server
|
||||
// unmarshals Params based on Method; an unknown Method ⇒ ErrUnknownMethod.
|
||||
type Request struct {
|
||||
Method Method `json:"m"`
|
||||
Params json.RawMessage `json:"p,omitempty"`
|
||||
}
|
||||
|
||||
// Response — one frame from core back to module. Exactly one of Result/Error
|
||||
// is set. Result is the JSON-encoded return value of the method (might be a
|
||||
// scalar, a struct, or null for void methods).
|
||||
type Response struct {
|
||||
Result json.RawMessage `json:"r,omitempty"`
|
||||
Error *RpcError `json:"e,omitempty"`
|
||||
}
|
||||
|
||||
// RpcError — a typed wire error. Code is one of the sentinel codes below;
|
||||
// the client rehydrates it into the matching package sentinel so callers can
|
||||
// use errors.Is like they would in-process (core's contract is the same on
|
||||
// both sides of the wire — the boundary shouldn't change error semantics).
|
||||
type RpcError struct {
|
||||
Code string `json:"c"`
|
||||
Message string `json:"m,omitempty"`
|
||||
}
|
||||
|
||||
func (e *RpcError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("ipc: %s: %s", e.Code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("ipc: %s", e.Code)
|
||||
}
|
||||
|
||||
// Sentinel codes. Stable over the wire — do not rename. Mirror the package
|
||||
// sentinels in api.go 1:1. The string is the contract.
|
||||
const (
|
||||
codeNoFact = "no_fact"
|
||||
codeConfidence = "confidence"
|
||||
codeVoidsMissing = "voids_missing"
|
||||
codeNudgeNotFound = "nudge_not_found"
|
||||
codeNudgeOutcome = "nudge_outcome"
|
||||
codeReminderMissing = "reminder_not_found"
|
||||
codeReminderState = "reminder_state"
|
||||
codeToolNotFound = "tool_not_found"
|
||||
codeUnknownMethod = "unknown_method"
|
||||
codeBadParams = "bad_params"
|
||||
codeForbidden = "forbidden"
|
||||
codeInternal = "internal"
|
||||
)
|
||||
|
||||
// codeOf maps a server-side sentinel to its wire code. Anything not matched
|
||||
// is codeInternal — we never leak internal Go error text to a module; it
|
||||
// gets a generic "internal" and the daemon logs the real error server-side.
|
||||
func codeOf(err error) string {
|
||||
switch {
|
||||
case err == nil:
|
||||
return ""
|
||||
case errors.Is(err, ErrNoFact):
|
||||
return codeNoFact
|
||||
case errors.Is(err, ErrConfidence):
|
||||
return codeConfidence
|
||||
case errors.Is(err, ErrVoidsMissing):
|
||||
return codeVoidsMissing
|
||||
case errors.Is(err, ErrNudgeNotFound):
|
||||
return codeNudgeNotFound
|
||||
case errors.Is(err, ErrNudgeOutcome):
|
||||
return codeNudgeOutcome
|
||||
case errors.Is(err, ErrReminderNotFound):
|
||||
return codeReminderMissing
|
||||
case errors.Is(err, ErrReminderState):
|
||||
return codeReminderState
|
||||
case errors.Is(err, ErrToolNotFound):
|
||||
return codeToolNotFound
|
||||
case errors.Is(err, ErrUnknownMethod):
|
||||
return codeUnknownMethod
|
||||
case errors.Is(err, ErrBadParams):
|
||||
return codeBadParams
|
||||
case errors.Is(err, ErrForbidden):
|
||||
return codeForbidden
|
||||
default:
|
||||
return codeInternal
|
||||
}
|
||||
}
|
||||
|
||||
// rpcErr builds the wire error for a server-side error. message is omitted
|
||||
// for sentinel codes (the Code carries the meaning; no need to echo text the
|
||||
// caller can re-derive from errors.Is) and included for internal/bad-params
|
||||
// where the text is the actual diagnostic.
|
||||
func rpcErr(err error) *RpcError {
|
||||
c := codeOf(err)
|
||||
if c == codeInternal || c == codeBadParams {
|
||||
return &RpcError{Code: c, Message: err.Error()}
|
||||
}
|
||||
return &RpcError{Code: c}
|
||||
}
|
||||
Reference in New Issue
Block a user