engine: HostExecutor interface for initializeCommand by bilby91 · Pull Request #33 · crunchloop/devcontainer · GitHub
Skip to content

engine: HostExecutor interface for initializeCommand#33

Merged
bilby91 merged 1 commit into
mainfrom
host-executor
May 10, 2026
Merged

engine: HostExecutor interface for initializeCommand#33
bilby91 merged 1 commit into
mainfrom
host-executor

Conversation

@bilby91

@bilby91 bilby91 commented May 10, 2026

Copy link
Copy Markdown
Member

Summary

  • New `HostExecutor` interface (`host_executor.go`) that callers supply via `EngineOptions.HostExecutor` to enable host-side spec hooks.
  • `initializeCommand` now dispatches through it (Single + Parallel forms; Parallel runs concurrently and reports the first non-zero exit, mirroring `execParallel`).
  • Sentinel `ErrHostExecutorNotConfigured` returned (wrapped in `*LifecycleError`) when a host hook is configured but no executor is set, so callers can `errors.Is` and surface a useful message.

Closes #11. Unblocks #23 (`secretsCommand`) — same interface; the follow-up just captures stdout and merges into `ContainerEnv`.

API surface

```go
type HostExecutor interface {
ExecHost(ctx context.Context, cmd HostCommand) (HostExecResult, error)
}

type HostCommand struct {
Shell string
Exec []string
Env map[string]string
WorkingDir string
}

type HostExecResult struct {
ExitCode int
Stdout, Stderr string
}
```

Mirrors `runtime.ExecOptions` / `runtime.ExecResult` so consumers building both sides reuse the same mental model. Engine sets `WorkingDir = LocalWorkspaceFolder` for spec hooks.

Why no default impl?

Host execution is the most security-sensitive surface in the spec. `devcontainer.json` is user-supplied; running `exec.Command` from inside a library against it would bake policy (env passthrough, timeouts, sandboxing) into the wrong place. Caller-supplied keeps the library spec-faithful and lets each embedder make the right call:

  • DAP: opt out entirely (no executor) or supply a sandboxed one.
  • A CLI front-end: supply a passthrough that inherits the host env.
  • A CI agent: supply one that strips secrets.

Test plan

  • `go test ./...` — all green
  • `go vet ./...`, `gofmt` clean
  • New tests:
    • `TestInitializeCommand_RequiresHostExecutor`: verifies the typed-error path via `errors.Is`.
    • `TestInitializeCommand_RoutesToHostExecutorSingle`: verifies `Shell` and `WorkingDir` propagate correctly.
    • `TestInitializeCommand_NonZeroExitProducesLifecycleError`: verifies host exit code reaches `*LifecycleError.ExitCode`.
    • `TestInitializeCommand_SkippedByDefault`: confirms `RunInitializeCommand=false` (default) bypasses the executor entirely.

No integration test — host execution is hostile to CI (would need a sandbox), and the contract is fully covered by unit tests via the in-memory `fakeHostExecutor`.

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced host executor configuration to the engine, enabling execution of security-sensitive initialization commands on the host.
    • Engine now properly routes and executes initializeCommand hooks with output capture and exit code reporting.
    • Support for both single and parallel initialization command execution modes.
    • Enhanced error messaging when host executor is not configured.
  • Tests

    • Added comprehensive test coverage for host executor integration and initialization command workflows.

Review Change Stack

… hooks)

Adds a caller-supplied HostExecutor interface that the engine
dispatches host-side spec hooks to. initializeCommand now routes
through it (Single + Parallel forms). secretsCommand will plug into
the same interface in a follow-up.

Library does NOT ship a default HostExecutor: host execution is
security-sensitive (devcontainer.json can declare arbitrary
commands), and policy decisions — sandboxing, env filtering,
timeouts, working dir — belong to the embedding application.
Unconfigured executor + a configured initializeCommand returns a
*LifecycleError wrapping a typed sentinel ErrHostExecutorNotConfigured
so callers can errors.Is and surface a useful message.

Closes #11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

@bilby91

bilby91 commented May 10, 2026

Copy link
Copy Markdown
Member Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented May 10, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
lifecycle_test.go (1)

302-369: ⚡ Quick win

Consider adding test coverage for the Parallel command form.

The tests comprehensively cover the Single command path, but runInitializeParallel (which handles initializeCommand as a map of named commands running concurrently) lacks direct test coverage. Given the concurrency logic involved, a test verifying parallel execution would strengthen confidence.

Example test skeleton
func TestInitializeCommand_RoutesToHostExecutorParallel(t *testing.T) {
	rt := newScriptedRuntime()
	hx := &fakeHostExecutor{}
	eng, _ := New(EngineOptions{Runtime: rt, HostExecutor: hx})
	ws := writeImageDevcontainer(t, `{
		"image":"alpine:3.20",
		"initializeCommand": {
			"setup": "echo setup",
			"prepare": "echo prepare"
		}
	}`)
	if _, err := eng.Up(context.Background(), UpOptions{
		LocalWorkspaceFolder: ws,
		RunInitializeCommand: true,
	}); err != nil {
		t.Fatalf("Up: %v", err)
	}
	if len(hx.calls) != 2 {
		t.Fatalf("expected 2 host calls, got %d", len(hx.calls))
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lifecycle_test.go` around lines 302 - 369, Add a unit test that exercises the
parallel form of initializeCommand by invoking eng.Up with RunInitializeCommand:
true on a devcontainer.json where "initializeCommand" is a map (e.g.
{"setup":"echo setup","prepare":"echo prepare"}), using newScriptedRuntime() and
a fakeHostExecutor to capture host calls; assert that runInitializeParallel
(indirectly via Up/EngineOptions and HostExecutor) results in two HostExecutor
calls (fakeHostExecutor.calls length == 2) and that each call’s Shell matches
the expected commands, and ensure the test mirrors existing patterns used in
TestInitializeCommand_RoutesToHostExecutorSingle and
TestInitializeCommand_SkippedByDefault.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@lifecycle_test.go`:
- Around line 302-369: Add a unit test that exercises the parallel form of
initializeCommand by invoking eng.Up with RunInitializeCommand: true on a
devcontainer.json where "initializeCommand" is a map (e.g. {"setup":"echo
setup","prepare":"echo prepare"}), using newScriptedRuntime() and a
fakeHostExecutor to capture host calls; assert that runInitializeParallel
(indirectly via Up/EngineOptions and HostExecutor) results in two HostExecutor
calls (fakeHostExecutor.calls length == 2) and that each call’s Shell matches
the expected commands, and ensure the test mirrors existing patterns used in
TestInitializeCommand_RoutesToHostExecutorSingle and
TestInitializeCommand_SkippedByDefault.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d54d45e2-4db2-4244-9310-ab89f570351a

📥 Commits

Reviewing files that changed from the base of the PR and between ad340b0 and 698bb2e.

📒 Files selected for processing (4)
  • engine.go
  • host_executor.go
  • lifecycle.go
  • lifecycle_test.go

@bilby91 bilby91 merged commit b245721 into main May 10, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

initializeCommand: HostExecutor wiring on EngineOptions

1 participant