okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petRestore the webhooks shortcut and keep agent access visiblef51cc1a

main
18.7 KiB461 linesraw
1//go:build unix
2
3// The independent adapter must carry the real daemon's responses into the TUI
4// without owning its launcher, workspace discovery, or wire-format decoding.
5package e2e
6
7import (
8	"context"
9	"encoding/json"
10	"os"
11	"path/filepath"
12	"slices"
13	"strings"
14	"testing"
15
16	"albedo/cli/internal/config"
17	"albedo/cli/internal/daemon"
18	"albedo/cli/internal/tui"
19
20	tea "charm.land/bubbletea/v2"
21)
22
23func TestIndependentAdapterDrivesContextTreeAndSessionCatalog(t *testing.T) {
24	profile := providerRoute(t, echoReply)
25	t.Parallel()
26	before := conn(t).Snapshot()
27	attached, err := daemon.Attach(t.Context(), before, nil)
28	if err != nil {
29		t.Fatal(err)
30	}
31	workspace := t.TempDir()
32	skill := filepath.Join(workspace, ".agents", "skills", "adapter-review", "SKILL.md")
33	if err := os.MkdirAll(filepath.Dir(skill), 0o700); err != nil {
34		t.Fatal(err)
35	}
36	if err := os.WriteFile(skill, []byte("---\nname: adapter-review\ndescription: Review an attached adapter workflow\n---\nInspect the prepared request.\n"), 0o600); err != nil {
37		t.Fatal(err)
38	}
39	session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: workspace, Provider: profile})
40	if err != nil {
41		t.Fatal(err)
42	}
43	pending, err := daemon.GetContextSnapshot(t.Context(), attached, session.ID)
44	if err != nil {
45		t.Fatalf("read context before its first turn: %v", err)
46	}
47	if pending.State != "pending" || strings.TrimSpace(pending.Reason) == "" {
48		t.Fatalf("pending context lost its readable reason: %+v", pending)
49	}
50	if pending.SnapshotID != "" || pending.CapturedAt != nil || len(pending.Sections) != 0 {
51		t.Fatalf("reading pending context prepared a snapshot: %+v", pending)
52	}
53	if requests := suite.provider.requests(profile); len(requests) != 0 {
54		t.Fatalf("reading pending context called the provider: %d requests", len(requests))
55	}
56	client := daemon.NewChatClient(attached, session.ID)
57	for index, prompt := range []string{"adapter durable first turn", "inspect adapter durable history"} {
58		if _, err := client.Send(t.Context(), prompt, nil); err != nil {
59			t.Fatal(err)
60		}
61		waitIdle(t, session.ID, profile, index+1)
62	}
63	preview, err := daemon.GetSessionPreview(t.Context(), attached, session.ID, 20)
64	if err != nil {
65		t.Fatal(err)
66	}
67	foundPreview := false
68	for _, item := range preview.Items {
69		foundPreview = foundPreview || strings.Contains(item.Preview, "inspect adapter durable history")
70	}
71	if !foundPreview {
72		t.Fatalf("preview omitted the completed turn: %+v", preview)
73	}
74	commands, err := daemon.ListSessionCommands(t.Context(), attached, session.ID)
75	if err != nil {
76		t.Fatal(err)
77	}
78	commandIndex := slices.IndexFunc(commands, func(command daemon.SessionCommand) bool {
79		return command.Name == "/adapter-review"
80	})
81	if commandIndex < 0 {
82		t.Fatal("enabled workspace skill is missing from the command menu catalog")
83	}
84	skillCommand := commands[commandIndex]
85	if skillCommand.Delivery != "input" || skillCommand.CommandID != "/adapter-review" || !skillCommand.UserTurn || skillCommand.Method != "PUT" || skillCommand.Description != "Review an attached adapter workflow" || len(skillCommand.Arguments) != 1 {
86		t.Fatalf("skill command lost its runnable metadata: %+v", skillCommand)
87	}
88	driver := driveTUIWithConnection(t, &session, attached)
89	defer driver.App.Chat.Close()
90	driver.Dispatch(tui.ChatOpenContextInspectorMsg{})
91	inspector := &driver.App.ContextInspector
92	if inspector.Error != "" || inspector.Snapshot == nil || inspector.Snapshot.State != "ready" {
93		t.Fatalf("prepared context did not reach inspector: %+v", inspector)
94	}
95	history := -1
96	for index, section := range inspector.Snapshot.Sections {
97		if section.Kind == "history" && section.Pages > 0 {
98			history = index
99			break
100		}
101	}
102	if history < 0 {
103		t.Fatal("prepared context has no readable history section")
104	}
105	inspector.Cursor = history
106	driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
107	if inspector.Detail == nil || inspector.Detail.Value == nil || !strings.Contains(inspector.Detail.Value.Content, "adapter durable first turn") {
108		t.Fatalf("context detail lost prior history: %+v", inspector.Detail)
109	}
110	driver.Dispatch(tui.ContextDoneMsg{})
111	driver.Dispatch(tui.ChatOpenTreePickerMsg{})
112	if driver.App.TreePicker.Error != "" || len(driver.App.TreePicker.Checkpoints) < 2 {
113		t.Fatalf("completed turns did not reach checkpoint picker: %+v", driver.App.TreePicker)
114	}
115	driver.Dispatch(tui.TreeCancelMsg{})
116	driver.Dispatch(tui.ChatOpenCapabilityPageMsg{Kind: "skills"})
117	page := &driver.App.CapabilityPage
118	selected := -1
119	for index, item := range page.Items {
120		if item.Candidate.Source == skill {
121			selected = index
122			break
123		}
124	}
125	if page.Error != "" || selected < 0 || !page.Items[selected].Candidate.EffectiveEnabled {
126		t.Fatalf("workspace skill did not reach catalog page: %+v", page)
127	}
128	page.Cursor = selected
129	driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModShift})
130	// The app also refreshes its command menu after this save, which starts
131	// recurring glance polls. Drive through the page's actual save and reload
132	// completion instead of asking the synchronous driver to settle timers.
133	queued := driver.results(driver.Update(tea.KeyPressMsg{Code: tea.KeyEnter}))
134	for step := 0; page.Saving || page.Loading; step++ {
135		if step == 16 || len(queued) == 0 {
136			t.Fatalf("catalog save did not finish: %+v", page)
137		}
138		message := queued[0]
139		queued = queued[1:]
140		command := driver.Update(message)
141		if page.Saving || page.Loading {
142			queued = append(queued, driver.results(command)...)
143		}
144	}
145	catalog, err := daemon.GetCapabilityCatalog(t.Context(), attached, session.ID)
146	if err != nil {
147		t.Fatal(err)
148	}
149	disabled := false
150	for _, candidate := range catalog.Candidates {
151		if candidate.Source == skill {
152			disabled = !candidate.EffectiveEnabled && candidate.SessionOverride != nil && !*candidate.SessionOverride
153		}
154	}
155	if page.Error != "" || !disabled {
156		t.Fatalf("session catalog toggle was not persisted: %+v", page)
157	}
158	commands, err = daemon.ListSessionCommands(t.Context(), attached, session.ID)
159	if err != nil {
160		t.Fatal(err)
161	}
162	if !slices.ContainsFunc(commands, func(command daemon.SessionCommand) bool { return command.Name == "/adapter-review" }) {
163		t.Fatal("desired skill selection prematurely replaced the loaded command catalogue")
164	}
165	if result, err := daemon.ReloadSession(t.Context(), attached, session.ID, daemon.ReloadRequest{Target: "session"}); err != nil || result.Session == nil || result.Session.Failure != nil {
166		t.Fatalf("reload disabled skill: %+v %v", result, err)
167	}
168	commands, err = daemon.ListSessionCommands(t.Context(), attached, session.ID)
169	if err != nil {
170		t.Fatal(err)
171	}
172	if slices.ContainsFunc(commands, func(command daemon.SessionCommand) bool {
173		return command.Name == "/adapter-review"
174	}) {
175		t.Fatal("disabled skill still appears as a runnable command")
176	}
177	after, err := readDaemonSnapshot(suite.home)
178	if err != nil || after.Port != before.Port || after.Pid != before.Pid || after.Token != before.Token || after.Version != before.Version || after.Build != before.Build {
179		t.Fatalf("independent attachment replaced its daemon: before=%+v after=%+v err=%v", before, after, err)
180	}
181}
182
183func TestTUICapabilityPageEnablesAndLoadsSkillCommands(t *testing.T) {
184	providerRoute(t, echoReply)
185	t.Parallel()
186	workspace := t.TempDir()
187	skill := filepath.Join(workspace, ".agents", "skills", "enable-review", "SKILL.md")
188	if err := os.MkdirAll(filepath.Dir(skill), 0o700); err != nil {
189		t.Fatal(err)
190	}
191	if err := os.WriteFile(skill, []byte("---\nname: enable-review\ndescription: Verify enabling the skills extension\n---\nReview the current task.\n"), 0o600); err != nil {
192		t.Fatal(err)
193	}
194	session := daemonSession(t, newSession(t, workspace))
195	if _, err := daemon.SelectExtension(t.Context(), conn(t), session.ID, daemon.ExtensionSelectionRequest{Name: "skills", Scope: "session", ETag: session.ETag, Enabled: new(false)}); err != nil {
196		t.Fatal(err)
197	}
198	if reloaded, err := daemon.ReloadSession(t.Context(), conn(t), session.ID, daemon.ReloadRequest{Target: "session"}); err != nil || reloaded.Session == nil || reloaded.Session.State != "applied" {
199		t.Fatalf("disable skills before opening its page: %+v, %v", reloaded, err)
200	}
201	commands, err := daemon.ListSessionCommands(t.Context(), conn(t), session.ID)
202	if err != nil || slices.ContainsFunc(commands, func(command daemon.SessionCommand) bool { return command.Name == "/enable-review" }) {
203		t.Fatalf("disabled skills retained runnable command: %+v, %v", commands, err)
204	}
205	session = daemonSession(t, session.ID)
206	driver := driveTUI(t, &session)
207	defer driver.App.Chat.Close()
208	driver.Dispatch(tui.ChatOpenCapabilityPageMsg{Kind: "skills"})
209	if driver.App.CapabilityPage.ExtensionEnabled {
210		t.Fatal("page did not observe the disabled skills extension")
211	}
212	driver.Dispatch(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
213	if !driver.App.CapabilityPage.Confirming() {
214		t.Fatal("ctrl+t did not request extension-enable confirmation")
215	}
216	driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
217	if page := driver.App.CapabilityPage; page.Error != "" || !page.ExtensionEnabled || !strings.Contains(page.Notice, "Reload completed.") {
218		t.Fatalf("confirmed extension enable did not reload:\n%s", driver.View())
219	}
220	commands, err = daemon.ListSessionCommands(t.Context(), conn(t), session.ID)
221	if err != nil || !slices.ContainsFunc(commands, func(command daemon.SessionCommand) bool { return command.Name == "/enable-review" }) {
222		t.Fatalf("enabled extension did not load the skill command: %+v, %v", commands, err)
223	}
224}
225
226func TestTypedMCPSecretsKeepReplaceRemoveAndClear(t *testing.T) {
227	profile := providerRoute(t, echoReply)
228	attached, err := daemon.Attach(t.Context(), conn(t).Snapshot(), nil)
229	if err != nil {
230		t.Fatal(err)
231	}
232	session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: t.TempDir(), Provider: profile})
233	if err != nil {
234		t.Fatal(err)
235	}
236	httpName, stdioName := "typed-secret-http", "typed-secret-stdio"
237	servers := map[string]config.MCPServer{
238		httpName:  {Type: "http", URL: "http://127.0.0.1:1/mcp", Enabled: new(false)},
239		stdioName: {Type: "stdio", Command: "python3", Args: []string{}, Enabled: new(false)},
240	}
241	t.Cleanup(func() {
242		for name := range servers {
243			if _, err := updateMCP(context.Background(), attached, session.ID, daemon.MCPUpdateRequest{Name: name, Server: nil, Secrets: daemon.MCPSecretsPatch{}}); err != nil {
244				t.Error(err)
245			}
246		}
247	})
248	save := func(name string, patch daemon.MCPSecretsPatch) daemon.Settings {
249		t.Helper()
250		server := servers[name]
251		if _, err := updateMCP(t.Context(), attached, session.ID, daemon.MCPUpdateRequest{Name: name, Server: &server, Secrets: patch}); err != nil {
252			t.Fatal(err)
253		}
254		settings, err := daemon.GetSettings(t.Context(), attached)
255		if err != nil {
256			t.Fatal(err)
257		}
258		return settings
259	}
260	save(httpName, daemon.MCPSecretsPatch{
261		BearerToken: new("initial-bearer"),
262		Headers:     map[string]*string{"x-retained": new("initial-header"), "x-removed": new("remove-header")},
263	})
264	save(stdioName, daemon.MCPSecretsPatch{Env: map[string]*string{"RETAINED": new("initial-env"), "REMOVED": new("remove-env")}})
265	keptHTTP := save(httpName, daemon.MCPSecretsPatch{}).Credentials.MCP[httpName]
266	keptStdio := save(stdioName, daemon.MCPSecretsPatch{}).Credentials.MCP[stdioName]
267	if !keptHTTP.BearerToken || !slices.Equal(keptHTTP.Headers, []string{"x-removed", "x-retained"}) || !slices.Equal(keptStdio.Env, []string{"REMOVED", "RETAINED"}) {
268		t.Fatalf("omitted secret patch changed saved names: HTTP=%+v stdio=%+v", keptHTTP, keptStdio)
269	}
270	replacedHTTP := save(httpName, daemon.MCPSecretsPatch{
271		BearerToken: new("replacement-bearer"),
272		Headers:     map[string]*string{"x-retained": new("replacement-header"), "x-removed": nil},
273	}).Credentials.MCP[httpName]
274	replacedStdio := save(stdioName, daemon.MCPSecretsPatch{Env: map[string]*string{"RETAINED": new("replacement-env"), "REMOVED": nil}}).Credentials.MCP[stdioName]
275	if !replacedHTTP.BearerToken || !slices.Equal(replacedHTTP.Headers, []string{"x-retained"}) || !slices.Equal(replacedStdio.Env, []string{"RETAINED"}) {
276		t.Fatalf("named removal changed retained secret names: HTTP=%+v stdio=%+v", replacedHTTP, replacedStdio)
277	}
278	// The fixture owns this home; inspect only its test values to distinguish
279	// actual replacement from omission without public secret disclosure.
280	data, err := os.ReadFile(filepath.Join(suite.home, "creds.json"))
281	if err != nil {
282		t.Fatal(err)
283	}
284	var stored struct {
285		MCP map[string]struct {
286			BearerToken string            `json:"bearerToken"`
287			Headers     map[string]string `json:"headers"`
288			Env         map[string]string `json:"env"`
289		} `json:"mcp"`
290	}
291	if err := json.Unmarshal(data, &stored); err != nil {
292		t.Fatal(err)
293	}
294	if stored.MCP[httpName].BearerToken != "replacement-bearer" || stored.MCP[httpName].Headers["x-retained"] != "replacement-header" || stored.MCP[stdioName].Env["RETAINED"] != "replacement-env" {
295		t.Fatal("typed replacement patch did not update persisted test secrets")
296	}
297	clearedHTTP := save(httpName, daemon.MCPSecretsPatch{RemoveBearerToken: true, ClearHeaders: true}).Credentials.MCP[httpName]
298	clearedStdio := save(stdioName, daemon.MCPSecretsPatch{ClearEnv: true}).Credentials.MCP[stdioName]
299	if clearedHTTP.Any() || clearedStdio.Any() {
300		t.Fatalf("explicit container removal retained secret names: HTTP=%+v stdio=%+v", clearedHTTP, clearedStdio)
301	}
302}
303
304func TestAttachedWebhookScreenCreatesConfiguresDisablesAndDeletesHook(t *testing.T) {
305	profile := providerRoute(t, echoReply)
306	attached, err := daemon.Attach(t.Context(), conn(t).Snapshot(), nil)
307	if err != nil {
308		t.Fatal(err)
309	}
310	session, err := daemon.CreateSession(t.Context(), attached, daemon.CreateSessionRequest{Workspace: t.TempDir(), Provider: profile})
311	if err != nil {
312		t.Fatal(err)
313	}
314	enableExtension(t, attached, session.ID, "webhooks")
315	driver := driveTUIWithConnection(t, &session, attached)
316	defer driver.App.Chat.Close()
317	driver.Dispatch(tui.ChatOpenWebhooksPageMsg{})
318	driver.Dispatch(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
319	if driver.App.WebhooksPage.Error != "" || driver.App.WebhooksPage.Form == nil {
320		t.Fatalf("cannot open webhook creation form: %+v", driver.App.WebhooksPage)
321	}
322	driver.Type("attached-alert")
323	driver.App.WebhooksPage.Form.Inputs["header"].SetValue("x-adapter-signature")
324	driver.App.WebhooksPage.Form.Inputs["prefix"].SetValue("adapter=")
325	driver.Dispatch(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
326	page := &driver.App.WebhooksPage
327	if page.Error != "" || page.Reveal == nil || page.Reveal.Secret == "" {
328		t.Fatalf("webhook form lost its generated signing secret: %+v", page)
329	}
330	listed, err := daemon.ListWebhooks(t.Context(), attached, session.ID)
331	if err != nil {
332		t.Fatal(err)
333	}
334	var hook *daemon.Webhook
335	for _, entry := range listed {
336		if entry.Hook.Session == session.ID && entry.Hook.Name == "attached-alert" {
337			copy := entry.Hook
338			hook = &copy
339		}
340	}
341	if hook == nil || hook.Header != "x-adapter-signature" || hook.Prefix != "adapter=" || !hook.Enabled {
342		t.Fatalf("form did not persist its configured hook: %+v", hook)
343	}
344	deleted := false
345	t.Cleanup(func() {
346		if deleted {
347			return
348		}
349		if _, err := daemon.DeleteWebhook(context.Background(), attached, hook.ID, hook.ETag); err != nil {
350			t.Error(err)
351		}
352	})
353	for _, patch := range []daemon.WebhookPatch{
354		{SignatureHeader: new("x-typed-signature"), SignaturePrefix: new("typed=")},
355		{Enabled: new(false)}, {Enabled: new(true)},
356	} {
357		updated, err := daemon.EditWebhook(t.Context(), attached, hook.ID, hook.ETag, patch)
358		if err != nil {
359			t.Fatal(err)
360		}
361		if updated.Hook == nil || updated.Hook.ID != hook.ID || updated.Hook.Session != session.ID || updated.Hook.Revision == hook.Revision || updated.Hook.Header != "x-typed-signature" || updated.Hook.Prefix != "typed=" {
362			t.Fatalf("typed edit lost updated configuration: %+v", updated)
363		}
364		if patch.Enabled != nil && updated.Hook.Enabled != *patch.Enabled {
365			t.Fatalf("typed edit lost enabled state: %+v", updated)
366		}
367		hook = updated.Hook
368		persisted, err := daemon.ListWebhooks(t.Context(), attached, session.ID)
369		if err != nil {
370			t.Fatal(err)
371		}
372		found := false
373		for _, entry := range persisted {
374			if entry.Hook.ID != hook.ID {
375				continue
376			}
377			found = true
378			configuration := entry.Hook
379			configuration.URL = ""
380			configuration.Address = ""
381			if configuration != *hook {
382				t.Fatalf("saved configuration differs: result=%+v saved=%+v", hook, entry.Hook)
383			}
384			copy := entry.Hook
385			hook = &copy
386		}
387		if !found {
388			t.Fatal("edited hook missing from list")
389		}
390	}
391	driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
392	selected := -1
393	for index, item := range page.Hooks {
394		if item.ID == hook.ID {
395			selected = index
396		}
397	}
398	if selected < 0 {
399		t.Fatal("created hook did not appear after secret dismissal")
400	}
401	driver.Update(tea.WindowSizeMsg{Width: 70, Height: 20})
402	for _, enabled := range []bool{true, false} {
403		if view := driver.View(); !strings.Contains(view, "ctrl+t") || !strings.Contains(view, "agent access") {
404			t.Fatalf("agent access is hidden on a narrow webhook page:\n%s", view)
405		}
406		driver.Dispatch(tea.KeyPressMsg{Code: 't', Mod: tea.ModCtrl})
407		permission, permissionErr := daemon.GetWebhookPermission(t.Context(), attached, session.ID)
408		if permissionErr != nil || permission.AgentManagement != enabled || driver.App.WebhooksPage.AgentManagement != enabled {
409			t.Fatalf("agent access toggle did not persist %t: %+v, %v\n%s", enabled, permission, permissionErr, driver.View())
410		}
411		driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEscape})
412		driver.Dispatch(tui.ChatOpenWebhooksPageMsg{})
413		if driver.App.WebhooksPage.Error != "" || driver.App.WebhooksPage.AgentManagement != enabled {
414			t.Fatalf("reopened page lost agent access %t:\n%s", enabled, driver.View())
415		}
416	}
417	driver.Dispatch(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
418	page.Cursor = selected
419	driver.Dispatch(tea.KeyPressMsg{Code: tea.KeyEnter})
420	driver.Dispatch(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
421	listed, err = daemon.ListWebhooks(t.Context(), attached, session.ID)
422	if err != nil {
423		t.Fatal(err)
424	}
425	disabled := false
426	for _, entry := range listed {
427		if entry.Hook.ID == hook.ID {
428			disabled = !entry.Hook.Enabled
429		}
430	}
431	if page.Error != "" || !disabled {
432		t.Fatalf("disabled hook did not survive reload: %+v", page)
433	}
434	// Deletion acknowledges its identity; authoritative reload removes it.
435	for _, entry := range listed {
436		if entry.Hook.ID == hook.ID {
437			hook = &entry.Hook
438		}
439	}
440	removed, err := daemon.DeleteWebhook(t.Context(), attached, hook.ID, hook.ETag)
441	if err != nil {
442		t.Fatal(err)
443	}
444	if removed.DeletedID != hook.ID {
445		t.Fatalf("typed deletion lost deleted hook: result=%+v saved=%+v", removed, hook)
446	}
447	deleted = true
448	driver.Dispatch(tea.KeyPressMsg{Code: 'r', Mod: tea.ModCtrl})
449	listed, err = daemon.ListWebhooks(t.Context(), attached, session.ID)
450	if err != nil {
451		t.Fatal(err)
452	}
453	for _, entry := range listed {
454		if entry.Hook.ID == hook.ID {
455			t.Fatal("deleted hook still appears in authoritative list")
456		}
457	}
458	if page.Error != "" {
459		t.Fatal(page.Error)
460	}
461}