-
Notifications
You must be signed in to change notification settings - Fork 179
feat(tui): full-auto permission mode, and classify local dev servers separately #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9937928
ff74090
0524931
07ce11f
d57e395
669631a
590e5e0
80fac7d
1d59752
27e6cc6
4a77c20
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -142,8 +142,8 @@ func TestACPEndToEndPrompt(t *testing.T) { | |
| if len(newRes.ConfigOptions) != 2 || newRes.ConfigOptions[0].ID != configIDModel || newRes.ConfigOptions[0].CurrentValue != "fake-model" { | ||
| t.Fatalf("model config option = %+v, want fake-model fallback", newRes.ConfigOptions) | ||
| } | ||
| if newRes.ConfigOptions[1].ID != configIDMode || newRes.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) { | ||
| t.Fatalf("mode config option = %+v", newRes.ConfigOptions[1]) | ||
| if mode := configOptionByID(t, newRes.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) { | ||
| t.Fatalf("mode config option = %+v", mode) | ||
| } | ||
|
|
||
| // session/prompt | ||
|
|
@@ -222,7 +222,7 @@ func TestACPModelConfigOptionsCatalogSelectionAndLoad(t *testing.T) { | |
| if len(loaded.ConfigOptions) != 2 || loaded.ConfigOptions[0].CurrentValue != "gpt-5.4-mini" { | ||
| t.Fatalf("load model option = %+v", loaded.ConfigOptions) | ||
| } | ||
| if loaded.ConfigOptions[1].CurrentValue != string(agent.PermissionModeAuto) { | ||
| if mode := configOptionByID(t, loaded.ConfigOptions, configIDMode); mode.CurrentValue != string(agent.PermissionModeAuto) { | ||
| t.Fatalf("load mode option = %+v", loaded.ConfigOptions[1]) | ||
| } | ||
| } | ||
|
|
@@ -420,7 +420,7 @@ func TestACPSetModeUpdatesSession(t *testing.T) { | |
| t.Fatalf("config mode options missing plan: %#v", planConfigured.ConfigOptions[1].Options) | ||
| } | ||
| // Unsafe must be rejected over ACP — a client can't self-grant no-prompt host access. | ||
| if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeUnsafe)}, &SetSessionModeResult{}); err == nil { | ||
| if err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: string(agent.PermissionModeFullAuto)}, &SetSessionModeResult{}); err == nil { | ||
| t.Fatal("expected Unsafe mode to be rejected over ACP") | ||
| } | ||
|
Comment on lines
+423
to
425
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win Test the This test checks As per coding guidelines, “Every behavior or security-boundary change requires a regression test, including failure paths.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| // An unknown mode must be rejected. | ||
|
|
@@ -429,6 +429,70 @@ func TestACPSetModeUpdatesSession(t *testing.T) { | |
| } | ||
| } | ||
|
|
||
| // The legacy "unsafe" spelling has to reach the full-auto arm of both mode | ||
| // doors, not fall through to "unknown mode". | ||
| // | ||
| // It is an accepted alias everywhere else in the tree, so a client that sends it | ||
| // is naming the mode ACP deliberately refuses. Unnormalized, both handlers | ||
| // answered "unknown mode: unsafe" — the mode was still refused, so nothing | ||
| // escalated, but the client was told the wrong thing about why: that the mode | ||
| // does not exist, rather than that this transport will not grant it. That | ||
| // invites retrying under another spelling instead of stopping. | ||
| // | ||
| // Both doors are checked because they are two entry points onto one contract, | ||
| // and the previous round of this bug was exactly one layer normalizing while | ||
| // another did not. | ||
| func TestACPRejectsLegacyUnsafeAliasAsDisallowedNotUnknown(t *testing.T) { | ||
| h := newHarness(t, testDeps(t)) | ||
| defer h.stop() | ||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
|
|
||
| var newRes NewSessionResult | ||
| if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil { | ||
| t.Fatalf("session/new: %v", err) | ||
| } | ||
|
|
||
| assertDisallowed := func(door string, err error) { | ||
| t.Helper() | ||
| if err == nil { | ||
| t.Fatalf("%s: legacy unsafe alias was accepted over ACP", door) | ||
| } | ||
| if !strings.Contains(err.Error(), "mode not permitted over ACP") { | ||
| t.Errorf("%s: error = %q, want the disallowed-mode message", door, err) | ||
| } | ||
| if strings.Contains(err.Error(), "unknown mode") { | ||
| t.Errorf("%s: error = %q, want the alias resolved instead of reported as unknown", door, err) | ||
| } | ||
| } | ||
|
|
||
| // Spelled literally on purpose: this is the value that travels over the wire, | ||
| // and the Go alias for it is the canonical string, not the legacy one. | ||
| const legacyModeID = "unsafe" | ||
| assertDisallowed("set_mode", h.client.Call(ctx, MethodSessionSetMode, | ||
| SetSessionModeParams{SessionID: newRes.SessionID, ModeID: legacyModeID}, &SetSessionModeResult{})) | ||
| assertDisallowed("set_config_option", h.client.Call(ctx, MethodSessionSetConfigOption, | ||
| SetSessionConfigOptionParams{SessionID: newRes.SessionID, ConfigID: configIDMode, Value: legacyModeID}, &SetSessionConfigOptionResult{})) | ||
|
|
||
| // Refusing must leave the session where it was, not in a half-applied state. | ||
| var configured SetSessionConfigOptionResult | ||
| if err := h.client.Call(ctx, MethodSessionSetConfigOption, SetSessionConfigOptionParams{ | ||
| SessionID: newRes.SessionID, ConfigID: configIDMode, Value: string(agent.PermissionModeAsk), | ||
| }, &configured); err != nil { | ||
| t.Fatalf("set_config_option ask: %v", err) | ||
| } | ||
| if got := configured.ConfigOptions[1].CurrentValue; got != string(agent.PermissionModeAsk) { | ||
| t.Fatalf("mode after the refusals = %q, want ask", got) | ||
| } | ||
|
|
||
| // A genuinely unknown mode must still say so, or the assertions above would | ||
| // hold for a handler that answered "not permitted" to everything. | ||
| err := h.client.Call(ctx, MethodSessionSetMode, SetSessionModeParams{SessionID: newRes.SessionID, ModeID: "bogus"}, &SetSessionModeResult{}) | ||
| if err == nil || !strings.Contains(err.Error(), "unknown mode") { | ||
| t.Fatalf("set_mode bogus = %v, want an unknown-mode error", err) | ||
| } | ||
| } | ||
|
|
||
| // TestACPPlanModeWiresPermissionModeIntoAgentOptions confirms selecting "plan" | ||
| // over ACP actually reaches agent.Options.PermissionMode for the next turn — | ||
| // the same gap this test's TUI counterpart covers for /plan on. | ||
|
|
@@ -599,3 +663,19 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri | |
| } | ||
| } | ||
| } | ||
|
|
||
| // configOptionByID finds an advertised option by its identity rather than by | ||
| // position. Asserting on ConfigOptions[1] made every one of these tests depend | ||
| // on the ORDER the options are advertised in: reordering them would move the | ||
| // assertion onto a different option, or panic, instead of failing with a | ||
| // message about the option it means. | ||
| func configOptionByID(t *testing.T, options []SessionConfigOption, id string) SessionConfigOption { | ||
| t.Helper() | ||
| for _, option := range options { | ||
| if option.ID == id { | ||
| return option | ||
| } | ||
| } | ||
| t.Fatalf("no config option %q was advertised; got %+v", id, options) | ||
| return SessionConfigOption{} | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.