tools: add scope column for capability model
Add a 'scope' TEXT column (default 'homelab') to the tools table so tools can be namespaced by scope (e.g. "homelab:restart", "datacenter:reboot"). Backward-compat: bare name defaults to "homelab" scope. Changes: - Migration #1: ALTER TABLE tools ADD COLUMN scope - store.Tool: add Scope field, update all SQL and scanTool() - ipc.Tool DTO and request types: add Scope field - CoreAPI interface: pass scope in ProposeTool/EnableTool - storeAPI adapters: forward scope - cmd/mavend/voice: pass scope (empty → homelab) - cmd/mavweb/tools: show scope column in UI tables, hidden fields - All tests updated for scope field - Migration test made dynamic (startVer = len(migrations))
This commit is contained in:
@@ -31,7 +31,7 @@ Notes:
|
|||||||
| # | Task | Commit | Status |
|
| # | Task | Commit | Status |
|
||||||
|---|------|--------|--------|
|
|---|------|--------|--------|
|
||||||
| 5 | **mavcaldav tests** — `cmd/mavcaldav/` (314 lines, 0 coverage). CalDAV polling, iCal parsing, value-change filtering | 6daa96b | done |
|
| 5 | **mavcaldav tests** — `cmd/mavcaldav/` (314 lines, 0 coverage). CalDAV polling, iCal parsing, value-change filtering | 6daa96b | done |
|
||||||
| 6 | **mavttsd tests** — `cmd/mavttsd/` (Piper handler). TTS worker protocol round-trip | — | pending |
|
| 6 | **mavttsd tests** — `cmd/mavttsd/` (Piper handler). TTS worker protocol round-trip | a80b919 | done |
|
||||||
| 7 | **voicesink tests** — `internal/delivery/voicesink/`. Voice channel dispatch, ErrNoSession mapping | ffef44f | done |
|
| 7 | **voicesink tests** — `internal/delivery/voicesink/`. Voice channel dispatch, ErrNoSession mapping | ffef44f | done |
|
||||||
| 8 | **mavweb tests** — extend to cover `main.go` routes (server setup, template parsing, route registration, startup flags) | 185f4f5 | done |
|
| 8 | **mavweb tests** — extend to cover `main.go` routes (server setup, template parsing, route registration, startup flags) | 185f4f5 | done |
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -719,7 +719,7 @@ func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) s
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return "не разобрала команду — попробуй иначе."
|
return "не разобрала команду — попробуй иначе."
|
||||||
}
|
}
|
||||||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, h.now())
|
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: propose tool %q: %v", name, err)
|
log.Printf("voice: propose tool %q: %v", name, err)
|
||||||
return "команды «" + name + "» нет в списке разрешённых."
|
return "команды «" + name + "» нет в списке разрешённых."
|
||||||
@@ -800,7 +800,7 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
|
|||||||
log.Printf("voice: skipping malformed tool config %+v", tc)
|
log.Printf("voice: skipping malformed tool config %+v", tc)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, now); err != nil {
|
if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, tc.Scope, now); err != nil {
|
||||||
log.Printf("voice: seed tool %q: %v", tc.Name, err)
|
log.Printf("voice: seed tool %q: %v", tc.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type fakeCore struct {
|
|||||||
historyErr error
|
historyErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, _ time.Time) error {
|
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||||
f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive
|
f.gotEnableName, f.gotEnableCmd, f.gotEnableDest = name, cmd, destructive
|
||||||
return f.enableErr
|
return f.enableErr
|
||||||
}
|
}
|
||||||
@@ -134,8 +134,8 @@ func (f *fakeCore) RevertFact(_ context.Context, _ string) (int64, error) {
|
|||||||
|
|
||||||
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
|
func TestHandleTools_GET_RendersAndEscapes(t *testing.T) {
|
||||||
core := &fakeCore{
|
core := &fakeCore{
|
||||||
proposed: []ipc.Tool{{Name: "<b>x", Utterance: "restart the <i>thing"}},
|
proposed: []ipc.Tool{{Name: "<b>x", Scope: "homelab", Utterance: "restart the <i>thing"}},
|
||||||
enabled: []ipc.Tool{{Name: "svc", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
|
enabled: []ipc.Tool{{Name: "svc", Scope: "", Cmd: []string{"systemctl", "restart"}, Destructive: true}},
|
||||||
}
|
}
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core)
|
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core)
|
||||||
|
|||||||
+8
-5
@@ -344,11 +344,12 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
|||||||
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
{{if .Msg}}<div class=msg>{{.Msg}}</div>{{end}}
|
||||||
<h2>proposed <small>({{len .Proposed}})</small></h2>
|
<h2>proposed <small>({{len .Proposed}})</small></h2>
|
||||||
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
{{if .Proposed}}<p>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
|
||||||
<table><tr><th>name</th><th>from utterance</th><th>enable as</th></tr>
|
<table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||||
{{range .Proposed}}<tr>
|
{{range .Proposed}}<tr>
|
||||||
<td><code>{{.Name}}</code></td><td>{{.Utterance}}</td>
|
<td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td>{{.Utterance}}</td>
|
||||||
<td><form method=post action=/tools>
|
<td><form method=post action=/tools>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=enable>
|
<input type=hidden name=action value=enable>
|
||||||
<input type=text name=cmd placeholder="systemctl restart" required>
|
<input type=text name=cmd placeholder="systemctl restart" required>
|
||||||
<label><input type=checkbox name=destructive> destructive</label>
|
<label><input type=checkbox name=destructive> destructive</label>
|
||||||
@@ -356,11 +357,12 @@ input[type=text]{width:22rem}code{background:#f4f4f4;padding:.1rem .3rem}
|
|||||||
</tr>{{end}}</table>
|
</tr>{{end}}</table>
|
||||||
{{else}}<p>none pending.</p>{{end}}
|
{{else}}<p>none pending.</p>{{end}}
|
||||||
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
<h2>enabled <small>({{len .Enabled}})</small></h2>
|
||||||
{{if .Enabled}}<table><tr><th>name</th><th>command</th><th></th><th></th></tr>
|
{{if .Enabled}}<table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
|
||||||
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
|
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td>{{.Scope}}</td><td><code>{{join .Cmd " "}}</code></td>
|
||||||
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
|
<td>{{if .Destructive}}<span class=d>destructive</span>{{end}}</td>
|
||||||
<td><form method=post action=/tools style=display:inline>
|
<td><form method=post action=/tools style=display:inline>
|
||||||
<input type=hidden name=name value="{{.Name}}">
|
<input type=hidden name=name value="{{.Name}}">
|
||||||
|
<input type=hidden name=scope value="{{.Scope}}">
|
||||||
<input type=hidden name=action value=disable>
|
<input type=hidden name=action value=disable>
|
||||||
<button>disable</button></form></td></tr>{{end}}</table>
|
<button>disable</button></form></td></tr>{{end}}</table>
|
||||||
{{else}}<p>none enabled.</p>{{end}}
|
{{else}}<p>none enabled.</p>{{end}}
|
||||||
@@ -435,13 +437,14 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
name := strings.TrimSpace(r.FormValue("name"))
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
switch action {
|
switch action {
|
||||||
case "enable":
|
case "enable":
|
||||||
|
scope := r.FormValue("scope")
|
||||||
cmd := strings.Fields(r.FormValue("cmd"))
|
cmd := strings.Fields(r.FormValue("cmd"))
|
||||||
destructive := r.FormValue("destructive") != ""
|
destructive := r.FormValue("destructive") != ""
|
||||||
if name == "" || len(cmd) == 0 {
|
if name == "" || len(cmd) == 0 {
|
||||||
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
http.Error(w, "name and cmd required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := core.EnableTool(ctx, name, cmd, destructive, time.Now()); err != nil {
|
if err := core.EnableTool(ctx, name, cmd, destructive, scope, time.Now()); err != nil {
|
||||||
log.Printf("tools: enable %q: %v", name, err)
|
log.Printf("tools: enable %q: %v", name, err)
|
||||||
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
http.Error(w, "enable failed: "+err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -363,10 +363,10 @@ func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.
|
|||||||
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) {
|
func (r *recordingAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ time.Time) error {
|
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ string, _ time.Time) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ type VoiceConfig struct {
|
|||||||
// must not fire from the voice path (they need a confirm on an authed surface).
|
// must not fire from the voice path (they need a confirm on an authed surface).
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
Cmd []string `json:"cmd"`
|
Cmd []string `json:"cmd"`
|
||||||
Destructive bool `json:"destructive,omitempty"`
|
Destructive bool `json:"destructive,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-2
@@ -157,6 +157,7 @@ type sinceResp struct {
|
|||||||
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
|
// inert scaffold; 'enabled' is runnable. The executor only runs 'enabled'.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
Cmd []string `json:"cmd"`
|
Cmd []string `json:"cmd"`
|
||||||
Destructive bool `json:"destructive"`
|
Destructive bool `json:"destructive"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -167,6 +168,7 @@ type Tool struct {
|
|||||||
|
|
||||||
type proposeToolReq struct {
|
type proposeToolReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
Utterance string `json:"utterance"`
|
Utterance string `json:"utterance"`
|
||||||
Ts time.Time `json:"ts"`
|
Ts time.Time `json:"ts"`
|
||||||
}
|
}
|
||||||
@@ -175,6 +177,7 @@ type proposeToolResp struct {
|
|||||||
}
|
}
|
||||||
type enableToolReq struct {
|
type enableToolReq struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
Cmd []string `json:"cmd"`
|
Cmd []string `json:"cmd"`
|
||||||
Destructive bool `json:"destructive"`
|
Destructive bool `json:"destructive"`
|
||||||
Ts time.Time `json:"ts"`
|
Ts time.Time `json:"ts"`
|
||||||
@@ -227,8 +230,9 @@ type CoreAPI interface {
|
|||||||
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
// Enable/DisableTool gate at AuthStepUp (allowlist mutation, human-only);
|
||||||
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
// ProposeTool is maven-callable (no step-up — she has no passkey).
|
||||||
// LookupTool/ListTools read them.
|
// LookupTool/ListTools read them.
|
||||||
ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error)
|
// scope defaults to "homelab" when empty.
|
||||||
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||||
|
EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error
|
||||||
DisableTool(ctx context.Context, name string) error
|
DisableTool(ctx context.Context, name string) error
|
||||||
LookupTool(ctx context.Context, name string) (Tool, error)
|
LookupTool(ctx context.Context, name string) (Tool, error)
|
||||||
ListTools(ctx context.Context, status string) ([]Tool, error)
|
ListTools(ctx context.Context, status string) ([]Tool, error)
|
||||||
|
|||||||
@@ -309,16 +309,16 @@ func (c *Client) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
func (c *Client) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||||
var r proposeToolResp
|
var r proposeToolResp
|
||||||
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Utterance: utterance, Ts: ts}, &r); err != nil {
|
if err := c.call(ctx, MethodProposeTool, proposeToolReq{Name: name, Scope: scope, Utterance: utterance, Ts: ts}, &r); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
return r.Proposed, nil
|
return r.Proposed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
func (c *Client) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||||
return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Cmd: cmd, Destructive: destructive, Ts: ts}, nil)
|
return c.call(ctx, MethodEnableTool, enableToolReq{Name: name, Scope: scope, Cmd: cmd, Destructive: destructive, Ts: ts}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) DisableTool(ctx context.Context, name string) error {
|
func (c *Client) DisableTool(ctx context.Context, name string) error {
|
||||||
|
|||||||
@@ -144,13 +144,13 @@ func (a *storeAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
func (a *storeAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||||
ok, err := a.s.ProposeTool(ctx, name, utterance, ts)
|
ok, err := a.s.ProposeTool(ctx, name, utterance, scope, ts)
|
||||||
return ok, mapErr(err)
|
return ok, mapErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
func (a *storeAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||||
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, ts))
|
return mapErr(a.s.EnableTool(ctx, name, cmd, destructive, scope, ts))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
func (a *storeAPI) DisableTool(ctx context.Context, name string) error {
|
||||||
@@ -184,8 +184,8 @@ func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error)
|
|||||||
|
|
||||||
func toTool(t store.Tool) Tool {
|
func toTool(t store.Tool) Tool {
|
||||||
return Tool{
|
return Tool{
|
||||||
Name: t.Name, Cmd: t.Cmd, Destructive: t.Destructive, Status: t.Status,
|
Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive,
|
||||||
Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
Status: t.Status, Utterance: t.Utterance, Created: t.CreatedTs, Updated: t.UpdatedTs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +567,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Ts)
|
ok, err := s.api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -578,7 +578,7 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
|||||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Ts)
|
return marshalResult(nil), s.api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
|
||||||
|
|
||||||
case MethodDisableTool:
|
case MethodDisableTool:
|
||||||
var p disableToolReq
|
var p disableToolReq
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import (
|
|||||||
// var migrations = []string{
|
// var migrations = []string{
|
||||||
// `ALTER TABLE ...;`, // #1
|
// `ALTER TABLE ...;`, // #1
|
||||||
// }
|
// }
|
||||||
var migrations = []string{}
|
var migrations = []string{
|
||||||
|
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`,
|
||||||
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
// user_version, each in its own transaction that also bumps user_version. Fails
|
// user_version, each in its own transaction that also bumps user_version. Fails
|
||||||
|
|||||||
@@ -18,20 +18,22 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
// Empty baseline slice leaves the DB at version 0.
|
// The 1 migration in the built-in slice (tools scope) was applied on Open.
|
||||||
if v := userVersion(t, s); v != 0 {
|
startVer := len(migrations)
|
||||||
t.Fatalf("fresh DB user_version = %d, want 0", v)
|
if v := userVersion(t, s); v != startVer {
|
||||||
|
t.Fatalf("fresh DB user_version = %d, want %d", v, startVer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append a fake migration and run it: creates a throwaway table, bumps to 1.
|
// Append a fake migration and run it: creates a throwaway table, bumps by 1.
|
||||||
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
|
migrations = append(migrations, `CREATE TABLE migrate_probe (id INTEGER PRIMARY KEY)`)
|
||||||
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
|
t.Cleanup(func() { migrations = migrations[:len(migrations)-1] })
|
||||||
|
|
||||||
if err := migrate(ctx, s.db); err != nil {
|
if err := migrate(ctx, s.db); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
if v := userVersion(t, s); v != 1 {
|
want := startVer + 1
|
||||||
t.Fatalf("after migrate user_version = %d, want 1", v)
|
if v := userVersion(t, s); v != want {
|
||||||
|
t.Fatalf("after migrate user_version = %d, want %d", v, want)
|
||||||
}
|
}
|
||||||
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
|
if _, err := s.db.ExecContext(ctx, "INSERT INTO migrate_probe DEFAULT VALUES"); err != nil {
|
||||||
t.Fatalf("probe table not created: %v", err)
|
t.Fatalf("probe table not created: %v", err)
|
||||||
@@ -41,7 +43,7 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
|||||||
if err := migrate(ctx, s.db); err != nil {
|
if err := migrate(ctx, s.db); err != nil {
|
||||||
t.Fatalf("migrate second run not idempotent: %v", err)
|
t.Fatalf("migrate second run not idempotent: %v", err)
|
||||||
}
|
}
|
||||||
if v := userVersion(t, s); v != 1 {
|
if v := userVersion(t, s); v != want {
|
||||||
t.Fatalf("after re-migrate user_version = %d, want 1", v)
|
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-15
@@ -9,11 +9,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tool — one act in the allowlist. Cmd is the fixed argv prefix run with the
|
// Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab").
|
||||||
// utterance's args appended (no shell). Status 'proposed' is a scaffold that
|
// Cmd is the fixed argv prefix run with the utterance's args appended (no
|
||||||
// drives nothing; 'enabled' is the human-flipped, runnable form.
|
// shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is
|
||||||
|
// the human-flipped, runnable form.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
Name string
|
Name string
|
||||||
|
Scope string
|
||||||
Cmd []string
|
Cmd []string
|
||||||
Destructive bool
|
Destructive bool
|
||||||
Status string // proposed | enabled
|
Status string // proposed | enabled
|
||||||
@@ -34,12 +36,16 @@ var (
|
|||||||
// false when a row (proposed or enabled) already existed. maven calls this when
|
// false when a row (proposed or enabled) already existed. maven calls this when
|
||||||
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
|
// she classifies an act whose verb isn't on the enabled allowlist — she drafts
|
||||||
// the registration; a human enables it. Never overwrites an enabled tool.
|
// the registration; a human enables it. Never overwrites an enabled tool.
|
||||||
func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
|
// scope defaults to "homelab" when empty.
|
||||||
|
func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||||
|
if scope == "" {
|
||||||
|
scope = "homelab"
|
||||||
|
}
|
||||||
res, err := s.db.ExecContext(ctx, `
|
res, err := s.db.ExecContext(ctx, `
|
||||||
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
||||||
VALUES (?, '[]', 0, 'proposed', ?, ?, ?)
|
VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?)
|
||||||
ON CONFLICT(name) DO NOTHING`,
|
ON CONFLICT(name) DO NOTHING`,
|
||||||
name, utterance, ts.UnixMilli(), ts.UnixMilli())
|
name, scope, utterance, ts.UnixMilli(), ts.UnixMilli())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("propose tool: %w", err)
|
return false, fmt.Errorf("propose tool: %w", err)
|
||||||
}
|
}
|
||||||
@@ -54,10 +60,14 @@ func (s *Store) ProposeTool(ctx context.Context, name, utterance string, ts time
|
|||||||
// human "enable" act (the authed surface calls it); it upserts so enabling a
|
// human "enable" act (the authed surface calls it); it upserts so enabling a
|
||||||
// name that was never proposed still works. An empty cmd is refused — an
|
// name that was never proposed still works. An empty cmd is refused — an
|
||||||
// enabled tool that runs nothing is a footgun, not a tool.
|
// enabled tool that runs nothing is a footgun, not a tool.
|
||||||
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, ts time.Time) error {
|
// scope defaults to "homelab" when empty.
|
||||||
|
func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||||
if len(cmd) == 0 {
|
if len(cmd) == 0 {
|
||||||
return ErrToolCmd
|
return ErrToolCmd
|
||||||
}
|
}
|
||||||
|
if scope == "" {
|
||||||
|
scope = "homelab"
|
||||||
|
}
|
||||||
raw, err := json.Marshal(cmd)
|
raw, err := json.Marshal(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("enable tool: %w", err)
|
return fmt.Errorf("enable tool: %w", err)
|
||||||
@@ -67,11 +77,11 @@ func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destr
|
|||||||
d = 1
|
d = 1
|
||||||
}
|
}
|
||||||
_, err = s.db.ExecContext(ctx, `
|
_, err = s.db.ExecContext(ctx, `
|
||||||
INSERT INTO tools (name, cmd, destructive, status, utterance, created_ts, updated_ts)
|
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
|
||||||
VALUES (?, ?, ?, 'enabled', '', ?, ?)
|
VALUES (?, ?, ?, ?, 'enabled', '', ?, ?)
|
||||||
ON CONFLICT(name) DO UPDATE SET cmd=excluded.cmd, destructive=excluded.destructive,
|
ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive,
|
||||||
status='enabled', updated_ts=excluded.updated_ts`,
|
status='enabled', updated_ts=excluded.updated_ts`,
|
||||||
name, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
|
name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("enable tool: %w", err)
|
return fmt.Errorf("enable tool: %w", err)
|
||||||
}
|
}
|
||||||
@@ -95,7 +105,7 @@ func (s *Store) DisableTool(ctx context.Context, name string) error {
|
|||||||
// LookupTool returns the tool by name. ErrToolNotFound when absent.
|
// LookupTool returns the tool by name. ErrToolNotFound when absent.
|
||||||
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||||
row := s.db.QueryRowContext(ctx, `
|
row := s.db.QueryRowContext(ctx, `
|
||||||
SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts
|
SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts
|
||||||
FROM tools WHERE name = ?`, name)
|
FROM tools WHERE name = ?`, name)
|
||||||
t, err := scanTool(row)
|
t, err := scanTool(row)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
@@ -106,7 +116,7 @@ func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) {
|
|||||||
|
|
||||||
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
|
// ListTools returns tools filtered by status ("" ⇒ all), name-sorted.
|
||||||
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||||
q := `SELECT name, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
|
q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools`
|
||||||
var args []any
|
var args []any
|
||||||
if status != "" {
|
if status != "" {
|
||||||
q += ` WHERE status = ?`
|
q += ` WHERE status = ?`
|
||||||
@@ -137,7 +147,7 @@ func scanTool(sc scanner) (Tool, error) {
|
|||||||
var cmdJSON string
|
var cmdJSON string
|
||||||
var d int
|
var d int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
if err := sc.Scan(&t.Name, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
|
if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil {
|
||||||
return Tool{}, err
|
return Tool{}, err
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
|
if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil {
|
||||||
|
|||||||
@@ -14,15 +14,32 @@ func TestToolLifecycle(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
if _, err := s.ProposeTool(ctx, "restart_svc", "restart the service", now); err != nil {
|
// Propose with empty scope → defaults to "homelab".
|
||||||
|
if _, err := s.ProposeTool(ctx, "restart_svc", "restart the service", "", now); err != nil {
|
||||||
t.Fatalf("propose: %v", err)
|
t.Fatalf("propose: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.EnableTool(ctx, "restart_svc", []string{"systemctl", "restart", "x"}, true, now); err != nil {
|
if err := s.EnableTool(ctx, "restart_svc", []string{"systemctl", "restart", "x"}, true, "", now); err != nil {
|
||||||
t.Fatalf("enable: %v", err)
|
t.Fatalf("enable: %v", err)
|
||||||
}
|
}
|
||||||
if tl, _ := s.LookupTool(ctx, "restart_svc"); tl.Status != "enabled" {
|
tl, _ := s.LookupTool(ctx, "restart_svc")
|
||||||
|
if tl.Status != "enabled" {
|
||||||
t.Fatalf("after enable: status=%q want enabled", tl.Status)
|
t.Fatalf("after enable: status=%q want enabled", tl.Status)
|
||||||
}
|
}
|
||||||
|
if tl.Scope != "homelab" {
|
||||||
|
t.Fatalf("after enable: scope=%q want homelab", tl.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propose with explicit scope.
|
||||||
|
if _, err := s.ProposeTool(ctx, "reboot", "reboot the server", "datacenter", now); err != nil {
|
||||||
|
t.Fatalf("propose with scope: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.EnableTool(ctx, "reboot", []string{"reboot"}, true, "datacenter", now); err != nil {
|
||||||
|
t.Fatalf("enable with scope: %v", err)
|
||||||
|
}
|
||||||
|
tl2, _ := s.LookupTool(ctx, "reboot")
|
||||||
|
if tl2.Scope != "datacenter" {
|
||||||
|
t.Fatalf("explicit scope: %q want datacenter", tl2.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DisableTool(ctx, "restart_svc"); err != nil {
|
if err := s.DisableTool(ctx, "restart_svc"); err != nil {
|
||||||
t.Fatalf("disable: %v", err)
|
t.Fatalf("disable: %v", err)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import (
|
|||||||
type API interface {
|
type API interface {
|
||||||
LookupTool(ctx context.Context, name string) (ipc.Tool, error)
|
LookupTool(ctx context.Context, name string) (ipc.Tool, error)
|
||||||
ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
|
ListTools(ctx context.Context, status string) ([]ipc.Tool, error)
|
||||||
ProposeTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error)
|
ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ func (f fakeAPI) ListTools(_ context.Context, status string) ([]ipc.Tool, error)
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool, error) {
|
func (f fakeAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ func (f fakeAPI) ProposeTool(_ context.Context, _, _ string, _ time.Time) (bool,
|
|||||||
// destructive needs confirm, and args land as argv (no shell) after the prefix.
|
// destructive needs confirm, and args land as argv (no shell) after the prefix.
|
||||||
func TestExec(t *testing.T) {
|
func TestExec(t *testing.T) {
|
||||||
api := fakeAPI{tools: map[string]ipc.Tool{
|
api := fakeAPI{tools: map[string]ipc.Tool{
|
||||||
"restart": {Name: "restart", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
|
"restart": {Name: "restart", Scope: "homelab", Cmd: []string{"systemctl", "restart"}, Status: "enabled"},
|
||||||
"drop": {Name: "drop", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
|
"drop": {Name: "drop", Scope: "homelab", Cmd: []string{"dropdb"}, Destructive: true, Status: "enabled"},
|
||||||
"draft": {Name: "draft", Cmd: []string{"x"}, Status: "proposed"},
|
"draft": {Name: "draft", Scope: "homelab", Cmd: []string{"x"}, Status: "proposed"},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
var gotArgv []string
|
var gotArgv []string
|
||||||
|
|||||||
Reference in New Issue
Block a user