A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(command): plumb shell runner through ExecContext

ExecContext now carries the active *interp.Runner, so commands that
need to dispatch a child argv (currently just `time`) can call
Runner.Subshell() and re-enter the shell's exec-handler chain instead
of poking at the registry directly. This lets `time CMD` reach shell
functions, aliases, and PATH binaries the same way the user would
have invoked CMD on its own.

`time.Impl` no longer holds a `Registry`; it shell-quotes the inner
argv, parses it as bash, and runs it through a subshell of ec.Runner.
Tests now build a runner whose ExecHandler dispatches into the test
registry. The not-found message moves from "time: NAME: command not
found" to the registry's "kefka: command not found: NAME"; tests
were loosened to match.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso e20d26e4 82083bd1

+102 -38
+5 -2
cmd/kefka/main.go
··· 46 46 47 47 fsys := osfs.New(".") 48 48 49 + var sh *interp.Runner 50 + 49 51 middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 50 52 return func(ctx context.Context, args []string) error { 51 - return reg.Exec(ctx, fsys, args) 53 + return reg.Exec(ctx, fsys, sh, args) 52 54 } 53 55 } 54 56 55 - sh, err := interp.New( 57 + var err error 58 + sh, err = interp.New( 56 59 interp.Interactive(true), 57 60 interp.StdIO(os.Stdin, os.Stdout, os.Stderr), 58 61 interp.ExecHandlers(middleware),
+7
command/command.go
··· 6 6 7 7 "github.com/go-git/go-billy/v5" 8 8 "mvdan.cc/sh/v3/expand" 9 + "mvdan.cc/sh/v3/interp" 9 10 ) 10 11 11 12 type ExecContext struct { ··· 14 15 Dir string 15 16 Environ expand.Environ 16 17 FS billy.Filesystem 18 + // Runner is the active shell runner. Commands that need to dispatch a 19 + // child command (for example, `time CMD`) should call Runner.Subshell() 20 + // and re-enter the shell so the call goes through the same exec handler 21 + // chain instead of poking at the registry directly. May be nil in 22 + // embedders or tests that have not wired up a runner. 23 + Runner *interp.Runner 17 24 } 18 25 19 26 type Execer interface {
+42 -16
command/internal/time/time.go
··· 12 12 stdtime "time" 13 13 14 14 "mvdan.cc/sh/v3/interp" 15 + "mvdan.cc/sh/v3/syntax" 15 16 "tangled.org/xeiaso.net/kefka/command" 16 - "tangled.org/xeiaso.net/kefka/command/registry" 17 17 ) 18 18 19 - // Impl times the execution of another registered command. 19 + // Impl times the execution of another command. 20 20 // 21 - // Registry must be non-nil for the command-execution path; without it, 22 - // `time CMD ...` cannot dispatch to the inner command. The no-command 23 - // path (silent success) still works without a registry. 24 - type Impl struct { 25 - Registry *registry.Impl 26 - } 21 + // The inner command runs inside a subshell of ec.Runner, so it goes back 22 + // through the shell's exec-handler chain (registered builtins, functions, 23 + // path lookup) instead of being dispatched directly. ec.Runner must be set 24 + // for the command-execution path; without it, `time CMD ...` cannot 25 + // dispatch to the inner command. The no-command path (silent success) 26 + // still works without a runner. 27 + type Impl struct{} 27 28 28 29 func (impl Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 29 30 if ec == nil { ··· 121 122 displayCommand := strings.Join(commandArgs, " ") 122 123 123 124 startTime := stdtime.Now() 124 - innerErr := runInner(ctx, impl.Registry, ec, commandArgs) 125 + innerErr := runInner(ctx, ec, commandArgs) 125 126 elapsedSeconds := stdtime.Since(startTime).Seconds() 126 127 127 128 var timingOutput string ··· 145 146 return innerErr 146 147 } 147 148 148 - func runInner(ctx context.Context, reg *registry.Impl, ec *command.ExecContext, commandArgs []string) error { 149 - if reg == nil { 149 + // runInner executes the timed command in a subshell of ec.Runner. The 150 + // command line is reassembled (with each argument shell-quoted) and parsed 151 + // as bash so it re-enters the runner's exec-handler chain — this is what 152 + // lets `time` dispatch to registered builtins, shell functions, or PATH 153 + // binaries the same way the user would have invoked them directly. 154 + func runInner(ctx context.Context, ec *command.ExecContext, commandArgs []string) error { 155 + if ec.Runner == nil { 150 156 fmt.Fprint(ec.Stderr, "time: exec not available\n") 151 157 return interp.ExitStatus(127) 152 158 } 153 - cmd, ok := reg.Get(commandArgs[0]) 154 - if !ok { 155 - fmt.Fprintf(ec.Stderr, "time: %s: command not found\n", commandArgs[0]) 156 - return interp.ExitStatus(127) 159 + 160 + var b strings.Builder 161 + for idx, a := range commandArgs { 162 + if idx > 0 { 163 + b.WriteByte(' ') 164 + } 165 + quoted, err := syntax.Quote(a, syntax.LangBash) 166 + if err != nil { 167 + fmt.Fprintf(ec.Stderr, "time: cannot quote argument %q: %v\n", a, err) 168 + return interp.ExitStatus(1) 169 + } 170 + b.WriteString(quoted) 157 171 } 158 - return cmd.Exec(ctx, ec, commandArgs[1:]) 172 + 173 + prog, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(strings.NewReader(b.String()), "<time>") 174 + if err != nil { 175 + fmt.Fprintf(ec.Stderr, "time: cannot parse command: %v\n", err) 176 + return interp.ExitStatus(1) 177 + } 178 + 179 + sub := ec.Runner.Subshell() 180 + if err := interp.StdIO(ec.Stdin, ec.Stdout, ec.Stderr)(sub); err != nil { 181 + fmt.Fprintf(ec.Stderr, "time: cannot configure subshell: %v\n", err) 182 + return interp.ExitStatus(1) 183 + } 184 + return sub.Run(ctx, prog) 159 185 } 160 186 161 187 func writeTimingFile(ec *command.ExecContext, outputFile, timingOutput string, appendMode bool) error {
+45 -18
command/internal/time/time_test.go
··· 82 82 return memfs.New() 83 83 } 84 84 85 + // newRunner builds an interp.Runner whose exec handler dispatches into reg. 86 + // This is what threads the registered Execers through Runner.Subshell().Run. 87 + func newRunner(t *testing.T, reg *registry.Impl, fsys billy.Filesystem) *interp.Runner { 88 + t.Helper() 89 + var sh *interp.Runner 90 + middleware := func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { 91 + return func(ctx context.Context, args []string) error { 92 + return reg.Exec(ctx, fsys, sh, args) 93 + } 94 + } 95 + var err error 96 + sh, err = interp.New(interp.ExecHandlers(middleware)) 97 + if err != nil { 98 + t.Fatalf("interp.New: %v", err) 99 + } 100 + return sh 101 + } 102 + 85 103 type runResult struct { 86 104 stdout string 87 105 stderr string ··· 91 109 func run(t *testing.T, args []string, opts ...func(*command.ExecContext)) runResult { 92 110 t.Helper() 93 111 var stdout, stderr bytes.Buffer 112 + fsys := newFS(t) 113 + reg := newRegistry(t) 94 114 ec := &command.ExecContext{ 95 115 Stdout: &stdout, 96 116 Stderr: &stderr, 97 117 Dir: ".", 98 - FS: newFS(t), 118 + FS: fsys, 119 + Runner: newRunner(t, reg, fsys), 99 120 } 100 121 for _, opt := range opts { 101 122 opt(ec) 102 123 } 103 - impl := Impl{Registry: newRegistry(t)} 104 - err := impl.Exec(context.Background(), ec, args) 124 + err := Impl{}.Exec(context.Background(), ec, args) 105 125 return runResult{ 106 126 stdout: stdout.String(), 107 127 stderr: stderr.String(), ··· 374 394 375 395 func TestTime_OutputToFile(t *testing.T) { 376 396 fs := memfs.New() 397 + reg := newRegistry(t) 377 398 var stdout, stderr bytes.Buffer 378 399 ec := &command.ExecContext{ 379 400 Stdout: &stdout, 380 401 Stderr: &stderr, 381 402 Dir: ".", 382 403 FS: fs, 404 + Runner: newRunner(t, reg, fs), 383 405 } 384 - impl := Impl{Registry: newRegistry(t)} 385 - err := impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fixed", "echo", "hi"}) 406 + err := Impl{}.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fixed", "echo", "hi"}) 386 407 if err != nil { 387 408 t.Fatalf("unexpected error: %v", err) 388 409 } ··· 406 427 io.WriteString(f, "previous\n") 407 428 f.Close() 408 429 430 + reg := newRegistry(t) 409 431 var stdout, stderr bytes.Buffer 410 432 ec := &command.ExecContext{ 411 433 Stdout: &stdout, 412 434 Stderr: &stderr, 413 435 Dir: ".", 414 436 FS: fs, 437 + Runner: newRunner(t, reg, fs), 415 438 } 416 - impl := Impl{Registry: newRegistry(t)} 417 - err = impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-a", "-f", "appended", "echo", "hi"}) 439 + err = Impl{}.Exec(context.Background(), ec, []string{"-o", "out.log", "-a", "-f", "appended", "echo", "hi"}) 418 440 if err != nil { 419 441 t.Fatalf("unexpected error: %v", err) 420 442 } ··· 434 456 io.WriteString(f, "previous\n") 435 457 f.Close() 436 458 459 + reg := newRegistry(t) 437 460 var stdout, stderr bytes.Buffer 438 461 ec := &command.ExecContext{ 439 462 Stdout: &stdout, 440 463 Stderr: &stderr, 441 464 Dir: ".", 442 465 FS: fs, 466 + Runner: newRunner(t, reg, fs), 443 467 } 444 - impl := Impl{Registry: newRegistry(t)} 445 - err = impl.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fresh", "echo", "hi"}) 468 + err = Impl{}.Exec(context.Background(), ec, []string{"-o", "out.log", "-f", "fresh", "echo", "hi"}) 446 469 if err != nil { 447 470 t.Fatalf("unexpected error: %v", err) 448 471 } ··· 470 493 if !errors.As(r.err, &status) || uint8(status) != 127 { 471 494 t.Errorf("err = %v, want ExitStatus(127)", r.err) 472 495 } 473 - if !strings.Contains(r.stderr, "does-not-exist: command not found") { 496 + if !strings.Contains(r.stderr, "command not found") || 497 + !strings.Contains(r.stderr, "does-not-exist") { 474 498 t.Errorf("stderr missing not-found message: %q", r.stderr) 475 499 } 476 500 } 477 501 478 - func TestTime_NilRegistry(t *testing.T) { 502 + func TestTime_NilRunner(t *testing.T) { 503 + // Without a runner, time has no way to dispatch the inner command, so 504 + // it short-circuits to a 127 with the same diagnostic the registry 505 + // path used to produce. 479 506 var stdout, stderr bytes.Buffer 480 507 ec := &command.ExecContext{ 481 508 Stdout: &stdout, ··· 483 510 Dir: ".", 484 511 FS: memfs.New(), 485 512 } 486 - impl := Impl{Registry: nil} 487 - err := impl.Exec(context.Background(), ec, []string{"echo", "hi"}) 513 + err := Impl{}.Exec(context.Background(), ec, []string{"echo", "hi"}) 488 514 var status interp.ExitStatus 489 515 if !errors.As(err, &status) || uint8(status) != 127 { 490 516 t.Errorf("err = %v, want ExitStatus(127)", err) ··· 495 521 } 496 522 497 523 func TestTime_StdinPassthrough(t *testing.T) { 524 + fs := memfs.New() 525 + reg := newRegistry(t) 498 526 var stdout, stderr bytes.Buffer 499 527 ec := &command.ExecContext{ 500 528 Stdin: strings.NewReader("piped through"), 501 529 Stdout: &stdout, 502 530 Stderr: &stderr, 503 531 Dir: ".", 504 - FS: memfs.New(), 532 + FS: fs, 533 + Runner: newRunner(t, reg, fs), 505 534 } 506 - impl := Impl{Registry: newRegistry(t)} 507 - err := impl.Exec(context.Background(), ec, []string{"stdin-echo"}) 535 + err := Impl{}.Exec(context.Background(), ec, []string{"stdin-echo"}) 508 536 if err != nil { 509 537 t.Fatalf("unexpected error: %v", err) 510 538 } ··· 514 542 } 515 543 516 544 func TestTime_NilExecContext(t *testing.T) { 517 - impl := Impl{Registry: newRegistry(t)} 518 - err := impl.Exec(context.Background(), nil, []string{"echo", "hi"}) 545 + err := Impl{}.Exec(context.Background(), nil, []string{"echo", "hi"}) 519 546 if err == nil || !strings.Contains(err.Error(), "nil ExecContext") { 520 547 t.Errorf("err = %v, want nil ExecContext error", err) 521 548 }
+1 -1
command/registry/coreutils/coreutils.go
··· 99 99 reg.Register("tac", tac.Impl{}) 100 100 reg.Register("tail", tail.Impl{}) 101 101 reg.Register("tee", tee.Impl{}) 102 - reg.Register("time", time.Impl{Registry: reg}) 102 + reg.Register("time", time.Impl{}) 103 103 reg.Register("touch", touch.Impl{}) 104 104 reg.Register("tr", tr.Impl{}) 105 105 reg.Register("tree", tree.Impl{})
+2 -1
command/registry/registry.go
··· 86 86 return nil 87 87 } 88 88 89 - func (i *Impl) Exec(ctx context.Context, fsys billy.Filesystem, args []string) error { 89 + func (i *Impl) Exec(ctx context.Context, fsys billy.Filesystem, runner *interp.Runner, args []string) error { 90 90 hc := interp.HandlerCtx(ctx) 91 91 92 92 if len(args) == 0 { ··· 109 109 Dir: i.Pwd(), 110 110 Environ: hc.Env, 111 111 FS: fsys, 112 + Runner: runner, 112 113 }, cmdArgs) 113 114 }