diff --git a/cmd/mavcaldav/main_test.go b/cmd/mavcaldav/main_test.go index 6972eda..f5210fb 100644 --- a/cmd/mavcaldav/main_test.go +++ b/cmd/mavcaldav/main_test.go @@ -12,7 +12,7 @@ import ( ) type fakeCore struct { - ipc.CoreAPI + ipc.UnimplementedCoreAPI facts map[string]ipc.Fact // composite key "key|source" → Fact writeLog []ipc.WriteFactReq writeErr error diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index 2966968..58ef334 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -97,96 +97,6 @@ func main() { } } -// lockedAPI is a dummy CoreAPI used while the daemon is locked. Every method -// returns errLocked. The wire protocol's StoreAPI methods all go through the -// Server dispatch on CoreAPI, so returning errLocked from each is correct. -type lockedAPI struct{} - -var _ ipc.CoreAPI = (*lockedAPI)(nil) - -func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) { - return 0, errLocked -} -func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) { - return ipc.Fact{}, errLocked -} -func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) { - return ipc.Fact{}, errLocked -} -func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { - return 0, errLocked -} -func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { - return ipc.Presence{}, errLocked -} -func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { - return 0, errLocked -} -func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { - return errLocked -} -func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) { - return nil, errLocked -} -func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { - return 0, errLocked -} -func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { - return errLocked -} -func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { - return nil, errLocked -} -func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) { - return nil, errLocked -} -func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) { - return nil, errLocked -} -func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) { - return nil, errLocked -} -func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { - return 0, errLocked -} -func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) { - return nil, errLocked -} -func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) { - return nil, errLocked -} -func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { - return false, errLocked -} -func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { - return errLocked -} -func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked } -func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked } -func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) { - return nil, errLocked -} -func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked } -func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { - return errLocked -} -func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { - return ipc.Tool{}, errLocked -} -func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) { - return nil, errLocked -} -func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked } -func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) { - return "", errLocked -} -func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) { - return ipc.TickTrace{}, errLocked -} -func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) { - return nil, errLocked -} - func run(args []string) error { cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config") wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)") @@ -364,8 +274,13 @@ func run(args []string) error { api.chatFn = voiceW.handler.handleText } } else { - // locked mode: dummy CoreAPI that returns errLocked for everything - coreAPI = &lockedAPI{} + // locked mode: no real store yet, so there's no meaningful CoreAPI to + // serve. srv.Check below is the actual guard — every CoreAPI call is + // refused before it reaches this value. This is just a safe non-nil + // placeholder: if the guard is ever bypassed by a bug, calls land + // here and fail loudly with ipc.ErrNotImplemented instead of a nil + // dereference or, worse, silently succeeding. + coreAPI = ipc.UnimplementedCoreAPI{} } // ----- IPC boundary (core ↔ modules) ----- @@ -376,7 +291,17 @@ func run(args []string) error { passkeySess := webauthn.NewPasskeySession(5 * time.Minute) - // Set Server.Check — in locked mode, block everything except unlock-path methods. + // Set Server.Check — the single authorization guard, run once by + // Server.dispatch before any CoreAPI method is called (see + // internal/ipc/server.go). In locked mode this is the ONLY thing + // standing between an unauthenticated caller and the store: it must + // default-deny, with an explicit allowlist for the two methods the + // unlock flow itself needs (MethodAssertStepUp, MethodUnlock — neither + // of which touches CoreAPI; dispatch handles them directly via + // srv.StepUp/srv.UnlockFn). Forgetting to allowlist a new unlock-path + // method fails safe (denied); forgetting to guard a new CoreAPI method + // is impossible because there is nothing left to forget — every method + // not in the allowlist is refused by construction. if locked { srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error { switch m { @@ -516,7 +441,7 @@ func run(args []string) error { tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines)) factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval)) - // Swap the CoreAPI from lockedAPI to the real store adapter. + // Swap the CoreAPI from the locked placeholder to the real store adapter. newAPI := &daemonAPI{ CoreAPI: ipc.NewStoreAPI(st), getTrace: tl.trace, diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index f13b26a..1b92a25 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -17,11 +17,12 @@ import ( ) // fakeCore records the mutating calls handleTools makes and returns canned -// tool lists / errors. Embedding ipc.CoreAPI (nil) satisfies the large +// tool lists / errors. Embedding ipc.UnimplementedCoreAPI satisfies the large // interface — only the methods the handlers touch are overridden; any other -// call would nil-panic, which is fine since the handlers never make them. +// call returns ipc.ErrNotImplemented instead of nil-panicking, so a test that +// accidentally exercises an undeclared method fails loudly. type fakeCore struct { - ipc.CoreAPI + ipc.UnimplementedCoreAPI proposed, enabled []ipc.Tool listErr error diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 1e19edc..c7c1872 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -7,7 +7,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/kami/maven/internal/ipc" ) @@ -359,8 +358,12 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) { // recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth // check must reject before reaching it, otherwise the refusal leaks into the -// fake's counts and we fail. +// fake's counts and we fail. Embeds ipc.UnimplementedCoreAPI so every method +// this test doesn't exercise returns ipc.ErrNotImplemented loudly instead of +// being hand-stubbed to a canned value nobody checks. type recordingAPI struct { + ipc.UnimplementedCoreAPI + writes int chats int } @@ -369,89 +372,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64, r.writes++ return int64(r.writes), nil } -func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) { - return ipc.Fact{}, ipc.ErrNoFact -} -func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) { - return ipc.Fact{}, ipc.ErrNoFact -} -func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) { - return 0, ipc.ErrNoFact -} -func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) { - return ipc.Presence{}, nil -} -func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ string) (int64, error) { - return 1, nil -} -func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil } -func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, error) { - return nil, nil -} -func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) { - return ipc.TickTrace{}, nil -} -func (r *recordingAPI) MorningStatus(_ context.Context) ([]ipc.MorningRoutineStatus, error) { - return nil, nil -} -func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) { - return 1, nil -} -func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error { - return nil -} -func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) { - return nil, nil -} -func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) { - return nil, nil -} -func (r *recordingAPI) CalendarEvents(_ context.Context, _, _ time.Time) ([]ipc.Fact, error) { - return nil, nil -} -func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) { - return nil, nil -} -func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) { - return 1, nil -} -func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) { - return nil, nil -} -func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) { - return nil, nil -} -func (r *recordingAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) { - return false, nil -} -func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ string, _ time.Time) error { - return nil -} -func (r *recordingAPI) DisableTool(_ context.Context, _ string) error { - return nil -} -func (r *recordingAPI) DeleteTool(_ context.Context, _ string) error { - return nil -} -func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) { - return ipc.Tool{}, ipc.ErrToolNotFound -} -func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) { - return nil, nil -} -func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) { - return 0, nil -} -func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { - return nil, nil -} -func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error { - return nil -} -func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error { - return nil -} func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) { r.chats++ return "echo: " + text, nil diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index 727b8ac..8e26091 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -410,95 +410,12 @@ func TestChatViaClient(t *testing.T) { } // chatTestAPI — a minimal CoreAPI that only implements Chat for testing. -type chatTestAPI struct{} +// Embeds UnimplementedCoreAPI so every other method fails loudly with +// ErrNotImplemented instead of needing 27 hand-written no-op stubs. +type chatTestAPI struct { + UnimplementedCoreAPI +} -func (a *chatTestAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) LatestFact(ctx context.Context, key string) (Fact, error) { - return Fact{}, ErrUnknownMethod -} -func (a *chatTestAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { - return Fact{}, ErrUnknownMethod -} -func (a *chatTestAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) Presence(ctx context.Context) (Presence, error) { - return Presence{}, ErrUnknownMethod -} -func (a *chatTestAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) MarkReminder(ctx context.Context, id int64, status string) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { - return false, ErrUnknownMethod -} -func (a *chatTestAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) DisableTool(ctx context.Context, name string) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { - return nil -} -func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error { - return ErrUnknownMethod -} -func (a *chatTestAPI) LookupTool(ctx context.Context, name string) (Tool, error) { - return Tool{}, ErrUnknownMethod -} -func (a *chatTestAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { - return nil, ErrUnknownMethod -} -func (a *chatTestAPI) RevertFact(ctx context.Context, key string) (int64, error) { - return 0, ErrUnknownMethod -} -func (a *chatTestAPI) TickTrace(ctx context.Context) (TickTrace, error) { - return TickTrace{}, ErrUnknownMethod -} -func (a *chatTestAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { - return nil, ErrUnknownMethod -} func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) { if text == "привет" { return "и тебе привет!", nil diff --git a/internal/ipc/unimplemented.go b/internal/ipc/unimplemented.go new file mode 100644 index 0000000..5407f96 --- /dev/null +++ b/internal/ipc/unimplemented.go @@ -0,0 +1,118 @@ +package ipc + +import ( + "context" + "errors" + "time" +) + +// ErrNotImplemented is returned by every UnimplementedCoreAPI method. It is +// deliberately distinct from ErrUnknownMethod (a wire-level "no such +// method exists" verdict) and from any daemon-level "locked" error: this one +// means "this method exists on CoreAPI, but the fake/adapter embedding +// UnimplementedCoreAPI never got a real implementation for it." A test that +// exercises an undeclared method fails loudly on this text instead of +// silently nil-panicking or being mistaken for a legitimate failure. +var ErrNotImplemented = errors.New("ipc: not implemented (unimplemented CoreAPI stub)") + +// UnimplementedCoreAPI is the gRPC Unimplemented*Server pattern applied to +// CoreAPI: embed it in a test double or adapter and override only the +// methods you actually exercise. Every method returns ErrNotImplemented, so +// a call that reaches an undeclared method fails loudly and specifically, +// rather than compiling to a silent no-op or nil-pointer panic. This +// replaces the old pattern of hand-writing all 30 no-op stubs per double — +// those were compiler-satisfying padding, not tests of anything. +type UnimplementedCoreAPI struct{} + +var _ CoreAPI = UnimplementedCoreAPI{} + +func (UnimplementedCoreAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) LatestFact(ctx context.Context, key string) (Fact, error) { + return Fact{}, ErrNotImplemented +} +func (UnimplementedCoreAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) { + return Fact{}, ErrNotImplemented +} +func (UnimplementedCoreAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) Presence(ctx context.Context) (Presence, error) { + return Presence{}, ErrNotImplemented +} +func (UnimplementedCoreAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) MarkReminder(ctx context.Context, id int64, status string) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { + return false, ErrNotImplemented +} +func (UnimplementedCoreAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) DismissProposedRoutine(ctx context.Context, id int64) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { + return ErrNotImplemented +} +func (UnimplementedCoreAPI) LookupTool(ctx context.Context, name string) (Tool, error) { + return Tool{}, ErrNotImplemented +} +func (UnimplementedCoreAPI) ListTools(ctx context.Context, status string) ([]Tool, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64, error) { + return 0, ErrNotImplemented +} +func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) { + return TickTrace{}, ErrNotImplemented +} +func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) { + return nil, ErrNotImplemented +} +func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) { + return "", ErrNotImplemented +}