Commit ffcb3af61a

ffcb3af61a60b30ba5f96bfbfd6ce3f0074ac921

parent: d299dba2d3

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 23:44 UTC

control: strip --json before the dispatcher's refusals

Scope, SSH-only, read-only, disabled, admin and pending refusals ran
before the flag was stripped, so a scripted caller got plain text on
stderr exactly when it needed the envelope. The flag is stripped right
after lookup now.

TestRefusalsHonourJSON covers four of the refusals.

Closes #109

Layout: unified · split

internal/control/control.go +11 −9
@@ -83,6 +83,17 @@ func Dispatch(c *Ctx, argv []string) int {
83 if !ok { 83 if !ok {
84 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0]) 84 return c.fail(protocol.ExitUsage, "unknown command %q", argv[0])
85 } 85 }
86 // Strip the global --json flag wherever it appears, before any
87 // refusal below: a scripted caller needs the envelope most when it is
88 // being told no (#109).
89 args := rest[:0:0]
90 for _, a := range rest {
91 if a == "--json" {
92 c.JSON = true
93 continue
94 }
95 args = append(args, a)
96 }
86 // A runner-scoped key reaches the runner protocol and nothing else, so 97 // A runner-scoped key reaches the runner protocol and nothing else, so
87 // the key a CI host holds cannot administer the instance. 98 // the key a CI host holds cannot administer the instance.
88 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") { 99 if c.Scope != "full" && !(c.Scope == "runner" && cmd.Path[0] == "runner") {
@@ -108,15 +119,6 @@ func Dispatch(c *Ctx, argv []string) int {
108 return c.fail(protocol.ExitDenied, 119 return c.fail(protocol.ExitDenied,
109 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)") 120 "your account is not active yet: verify your email first (email verify <code>, or ask for the mail again with email add)")
110 } 121 }
111 // Strip the global --json flag wherever it appears.
112 args := rest[:0:0]
113 for _, a := range rest {
114 if a == "--json" {
115 c.JSON = true
116 continue
117 }
118 args = append(args, a)
119 }
120 if !cmd.ReadsStdin { 122 if !cmd.ReadsStdin {
121 c.Stdin = emptyReader{} 123 c.Stdin = emptyReader{}
122 } 124 }
internal/control/control_test.go +30
@@ -193,3 +193,33 @@ func TestAdminNounGatedInDispatch(t *testing.T) {
193 } 193 }
194 } 194 }
195} 195}
196
197// TestRefusalsHonourJSON: a refusal from the dispatcher's own checks is a
198// JSON envelope when --json was given, like any other failure. The flag
199// used to be stripped after those checks, so a read-only token or a
200// pending account got plain text on stderr exactly when a script needed
201// to parse the error (#109).
202func TestRefusalsHonourJSON(t *testing.T) {
203 cases := []struct {
204 name string
205 ctx Ctx
206 argv []string
207 }{
208 {"read-only token", Ctx{ReadOnly: true, Scope: "full", ViaAPI: true}, []string{"repo", "create", "a/b", "--json"}},
209 {"pending account", Ctx{User: store.User{Pending: true}, Scope: "full"}, []string{"repo", "list", "--json"}},
210 {"git-scoped key", Ctx{Scope: "git"}, []string{"whoami", "--json"}},
211 {"non-admin", Ctx{Scope: "full"}, []string{"admin", "stats", "--json"}},
212 }
213 for _, tc := range cases {
214 var out, errOut bytes.Buffer
215 c := tc.ctx
216 c.Stdout, c.Stderr = &out, &errOut
217 if code := Dispatch(&c, tc.argv); code != protocol.ExitDenied {
218 t.Errorf("%s: exit %d, want %d", tc.name, code, protocol.ExitDenied)
219 }
220 var env protocol.Envelope
221 if err := json.Unmarshal(out.Bytes(), &env); err != nil || env.Error == "" {
222 t.Errorf("%s: no JSON envelope on stdout: %q (stderr %q)", tc.name, out.String(), errOut.String())
223 }
224 }
225}