diff --git a/README.md b/README.md index a6ab041..0e24054 100644 --- a/README.md +++ b/README.md @@ -340,11 +340,19 @@ their own resource lifecycle rather than depend on these command processes. The default environment remains inherited, with optional `Env` overrides; client-assigned handshake, certificate, and socket metadata retain precedence. +`Options.Env` only supplies overrides; listing a few variables there does not +restrict inheritance. A deliberate reduced environment requires go-plugin +`ClientConfig.SkipHostEnv = true` together with explicit `Options.Env` values. +This affects the runtime environment, not the trusted supervisor's own inherited +environment or OS-required variables such as Windows `SYSTEMROOT`. `Directory` sets the runtime working directory. The additional supervisor start must be included in startup measurements before selecting session reuse. Streaming stress and real-runtime trust/environment compatibility remain separate gates before runtime cutover. The streaming probes below cover the -owned transport; real-runtime compatibility remains outstanding. +owned transport; real-runtime compatibility remains outstanding. The +[Runtime Protocol reference](https://devsy.sh/docs/developing-providers/runtime-protocol) +records the planned Devsy host environment and executable trust policy; +external runtime host integration is not implemented yet. ## Streaming stress under process ownership diff --git a/supervisor/environment_fixture_test.go b/supervisor/environment_fixture_test.go new file mode 100644 index 0000000..a2f2163 --- /dev/null +++ b/supervisor/environment_fixture_test.go @@ -0,0 +1,171 @@ +package supervisor + +import ( + "bytes" + "encoding/json" + "io" + "os" + "os/exec" + "sync" + + sdkplugin "github.com/devsy-org/devsy-runtime-sdk/plugin" + "github.com/devsy-org/devsy-runtime-sdk/runtimev1" + "github.com/devsy-org/devsy-runtime-sdk/server" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type environmentEntry struct { + Present bool + Value []byte +} + +type environmentReport struct { + Plugin map[string]environmentEntry + Child map[string]environmentEntry +} + +var compatibilityKeys = []string{ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "HOME", + "XDG_CONFIG_HOME", + "XDG_DATA_HOME", + "XDG_CACHE_HOME", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "TMPDIR", + "TMP", + "TEMP", + "PATH", + "DEVSY_ENV_PROBE_RUNTIME_CONFIG", + "DEVSY_ENV_PROBE_SECRET", +} + +func environmentSnapshot() map[string]environmentEntry { + keys := append([]string{}, compatibilityKeys...) + keys = append( + keys, + sdkplugin.Handshake().MagicCookieKey, + "PLUGIN_PROTOCOL_VERSIONS", + "PLUGIN_CLIENT_CERT", + "PLUGIN_MULTIPLEX_GRPC", + "PLUGIN_MIN_PORT", + "PLUGIN_MAX_PORT", + "PLUGIN_UNIX_SOCKET_DIR", + ) + values := make(map[string]environmentEntry, len(keys)) + for _, key := range keys { + value, present := os.LookupEnv(key) + values[key] = environmentEntry{Present: present, Value: []byte(value)} + } + return values +} + +func environmentChildMain() { + if err := json.NewEncoder(os.Stdout).Encode(environmentSnapshot()); err != nil { + panic(err) + } +} + +type environmentFixture struct { + runtimev1.UnimplementedRuntimeDriverServer +} + +func serveEnvironment() { + if _, err := io.WriteString(os.Stderr, "environment fixture serving\n"); err != nil { + panic(err) + } + server.Serve(&environmentFixture{}) +} + +func (*environmentFixture) Exec( + s grpc.BidiStreamingServer[runtimev1.ExecClientMessage, runtimev1.ExecServerMessage], +) error { + if err := environmentStart(s); err != nil { + return err + } + report, err := childEnvironment() + if err != nil { + return status.Error(codes.Internal, "environment child failed") + } + data, err := json.Marshal(report) + if err != nil { + return err + } + if len(data) > runtimev1.ChunkSize { + return status.Error(codes.Internal, "fixture report exceeds frame bound") + } + if err := s.Send(&runtimev1.ExecServerMessage{Payload: &runtimev1.ExecServerMessage_Stdout{ + Stdout: &runtimev1.OutputChunk{Data: data}, + }}); err != nil { + return err + } + return s.Send( + &runtimev1.ExecServerMessage{ + Payload: &runtimev1.ExecServerMessage_Exit{Exit: &runtimev1.ExecExit{}}, + }, + ) +} + +func environmentStart( + s grpc.BidiStreamingServer[runtimev1.ExecClientMessage, runtimev1.ExecServerMessage], +) error { + first, err := s.Recv() + if err != nil { + return err + } + if first.GetStart() == nil { + return status.Error(codes.InvalidArgument, "expected Start") + } + closed, err := s.Recv() + if err != nil { + return err + } + if closed.GetCloseStdin() == nil { + return status.Error(codes.InvalidArgument, "expected CloseStdin") + } + if _, err := s.Recv(); err != io.EOF { + return status.Error(codes.InvalidArgument, "expected send-side EOF") + } + return nil +} + +func childEnvironment() (environmentReport, error) { + report := environmentReport{Plugin: environmentSnapshot()} + binary, err := os.Executable() + if err != nil { + return report, err + } + // #nosec G204 -- Absolute self-executable test fixture; no PATH resolution. + command := exec.Command(binary, "--helper-role=environment-child") + data, err := command.Output() + if err != nil { + return report, err + } + err = json.Unmarshal(data, &report.Child) + return report, err +} + +type environmentDiagnostics struct { + mu sync.Mutex + data bytes.Buffer +} + +func (d *environmentDiagnostics) Write(data []byte) (int, error) { + d.mu.Lock() + defer d.mu.Unlock() + return d.data.Write(data) +} + +func (d *environmentDiagnostics) snapshot() []byte { + d.mu.Lock() + defer d.mu.Unlock() + return bytes.Clone(d.data.Bytes()) +} diff --git a/supervisor/environment_test.go b/supervisor/environment_test.go new file mode 100644 index 0000000..1092792 --- /dev/null +++ b/supervisor/environment_test.go @@ -0,0 +1,256 @@ +package supervisor + +import ( + "bytes" + "context" + "encoding/json" + "io" + "maps" + "os" + "path/filepath" + "testing" + "time" + + sdkplugin "github.com/devsy-org/devsy-runtime-sdk/plugin" + "github.com/devsy-org/devsy-runtime-sdk/runtimev1" + "github.com/hashicorp/go-hclog" + hplugin "github.com/hashicorp/go-plugin" +) + +const ( + environmentCanary = "__env_probe_secret__" + argumentCanary = "__argv_probe_secret__" +) + +type environmentPolicy struct { + skipHost bool + overrides []string + want map[string]string +} + +func TestOwnedRuntimeEnvironmentPolicy(t *testing.T) { + root, err := os.MkdirTemp("", "ep-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + inherited := compatibilityEnvironment(root) + for key, value := range inherited { + t.Setenv(key, value) + } + cases := map[string]environmentPolicy{ + "inherited": {want: inherited}, + "overrides": { + overrides: []string{"HTTP_PROXY=", "DOCKER_CONTEXT=provider-context"}, + want: overrideEnvironment(inherited), + }, + "explicit-allowlist": { + skipHost: true, overrides: []string{"NO_PROXY=" + inherited["NO_PROXY"]}, + want: map[string]string{"NO_PROXY": inherited["NO_PROXY"]}, + }, + } + for name, policy := range cases { + t.Run(name, func(t *testing.T) { checkEnvironmentPolicy(t, root, policy) }) + } +} + +func compatibilityEnvironment(root string) map[string]string { + values := make(map[string]string, len(compatibilityKeys)) + for _, key := range compatibilityKeys { + values[key] = "fixture " + key + " λ" + } + values["HTTP_PROXY"] = "http://" + environmentCanary + "@proxy.invalid:3128" + values["HTTPS_PROXY"] = values["HTTP_PROXY"] + values["NO_PROXY"] = "localhost,127.0.0.1,::1" + // These directories exist before environment mutation, including Windows TEMP/TMP. + for _, key := range []string{"TMPDIR", "TMP", "TEMP", "PATH"} { + values[key] = root + } + for _, key := range []string{"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "DOCKER_CONFIG"} { + values[key] = filepath.Join(root, key+" config λ") + } + values["DEVSY_ENV_PROBE_SECRET"] = environmentCanary + return values +} + +func overrideEnvironment(base map[string]string) map[string]string { + values := make(map[string]string, len(base)) + maps.Copy(values, base) + values["HTTP_PROXY"] = "" + values["DOCKER_CONTEXT"] = "provider-context" + return values +} + +func environmentClient( + t *testing.T, + root string, + policy environmentPolicy, +) (*hplugin.Client, *environmentDiagnostics) { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + diagnostics := &environmentDiagnostics{} + overrides := append([]string{}, policy.overrides...) + overrides = append( + overrides, + sdkplugin.Handshake().MagicCookieKey+"=stale-cookie", + "PLUGIN_PROTOCOL_VERSIONS=99", + "PLUGIN_CLIENT_CERT=stale-certificate", + "PLUGIN_MULTIPLEX_GRPC=false", + "PLUGIN_MIN_PORT=1", + "PLUGIN_MAX_PORT=1", + "PLUGIN_UNIX_SOCKET_DIR="+filepath.Join(root, "stale"), + ) + client := hplugin.NewClient(&hplugin.ClientConfig{ + HandshakeConfig: sdkplugin.Handshake(), + VersionedPlugins: map[int]hplugin.PluginSet{ + sdkplugin.ProtocolVersion: sdkplugin.ClientPlugins(), + }, + AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC}, + RunnerFunc: Runner(Options{ + SupervisorBinary: binary, + SupervisorArgs: []string{supervisorFixtureRole}, + RuntimeBinary: binary, + Args: []string{"--helper-role=environment", argumentCanary}, + Env: overrides, + }), + SkipHostEnv: policy.skipHost, + AutoMTLS: true, + GRPCBrokerMultiplex: true, + MinPort: 20000, + MaxPort: 30000, + StartTimeout: 15 * time.Second, + Logger: hclog.NewNullLogger(), + Stderr: diagnostics, + SyncStderr: diagnostics, + UnixSocketConfig: &hplugin.UnixSocketConfig{TempDir: root}, + }) + t.Cleanup(client.Kill) + return client, diagnostics +} + +func checkEnvironmentPolicy(t *testing.T, root string, policy environmentPolicy) { + t.Helper() + client, diagnostics := environmentClient(t, root, policy) + report := environmentRPC(t, client) + for role, values := range map[string]map[string]environmentEntry{"plugin": report.Plugin, "child": report.Child} { + assertCompatibility(t, role, values, policy.want) + assertTransportEnvironment(t, values) + } + client.Kill() + if !client.Exited() { + t.Fatal("environment supervisor was not reaped") + } + if !bytes.Contains(diagnostics.snapshot(), []byte("environment fixture serving")) { + t.Fatal("fixture diagnostics were not captured") + } + for _, secret := range []string{environmentCanary, argumentCanary} { + if bytes.Contains(diagnostics.snapshot(), []byte(secret)) { + t.Fatal("diagnostics exposed a secret canary") + } + } +} + +func environmentRPC(t *testing.T, client *hplugin.Client) environmentReport { + t.Helper() + rpc, err := client.Client() + if err != nil { + t.Fatal(err) + } + raw, err := rpc.Dispense(sdkplugin.Name) + if err != nil { + t.Fatal(err) + } + driver, ok := raw.(runtimev1.RuntimeDriverClient) + if !ok { + t.Fatal("unexpected environment fixture client") + } + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + stream, err := driver.Exec(ctx) + if err != nil { + t.Fatal(err) + } + sendEnvironmentStart(t, stream) + return readEnvironmentReport(t, stream) +} + +func sendEnvironmentStart(t *testing.T, s runtimev1.RuntimeDriver_ExecClient) { + t.Helper() + if err := s.Send(&runtimev1.ExecClientMessage{Payload: &runtimev1.ExecClientMessage_Start{ + Start: &runtimev1.ExecStart{Argv: []string{"inspect"}}, + }}); err != nil { + t.Fatal(err) + } + if err := s.Send(&runtimev1.ExecClientMessage{Payload: &runtimev1.ExecClientMessage_CloseStdin{ + CloseStdin: &runtimev1.CloseStdin{}, + }}); err != nil { + t.Fatal(err) + } + if err := s.CloseSend(); err != nil { + t.Fatal(err) + } +} + +func readEnvironmentReport(t *testing.T, s runtimev1.RuntimeDriver_ExecClient) environmentReport { + t.Helper() + frame, err := s.Recv() + if err != nil { + t.Fatal(err) + } + var report environmentReport + if err := json.Unmarshal(frame.GetStdout().GetData(), &report); err != nil { + t.Fatal(err) + } + terminal, err := s.Recv() + if err != nil || terminal.GetExit() == nil { + t.Fatalf("missing environment exit: %v", err) + } + if terminal.GetExit().GetExitCode() != 0 || terminal.GetExit().GetSignal() != "" { + t.Fatal("environment command failed") + } + if _, err := s.Recv(); err != io.EOF { + t.Fatalf("environment completion: %v", err) + } + return report +} + +func assertCompatibility( + t *testing.T, + role string, + got map[string]environmentEntry, + want map[string]string, +) { + t.Helper() + for _, key := range compatibilityKeys { + expected, present := want[key] + actual := got[key] + if actual.Present != present || !bytes.Equal(actual.Value, []byte(expected)) { + t.Fatalf("%s compatibility setting changed: %s", role, key) + } + } +} + +func assertTransportEnvironment(t *testing.T, got map[string]environmentEntry) { + t.Helper() + expected := map[string]string{ + sdkplugin.Handshake().MagicCookieKey: sdkplugin.Handshake().MagicCookieValue, + "PLUGIN_PROTOCOL_VERSIONS": "1", + "PLUGIN_MULTIPLEX_GRPC": "true", + "PLUGIN_MIN_PORT": "20000", + "PLUGIN_MAX_PORT": "30000", + } + for key, value := range expected { + if !got[key].Present || string(got[key].Value) != value { + t.Fatalf("transport metadata overridden: %s", key) + } + } + if !bytes.HasPrefix(got["PLUGIN_CLIENT_CERT"].Value, []byte("-----BEGIN CERTIFICATE-----")) { + t.Fatal("client certificate lost") + } + if !got["PLUGIN_UNIX_SOCKET_DIR"].Present { + t.Fatal("socket metadata lost") + } +} diff --git a/supervisor/runner.go b/supervisor/runner.go index df4c4b5..c1f72e8 100644 --- a/supervisor/runner.go +++ b/supervisor/runner.go @@ -20,8 +20,10 @@ import ( "github.com/hashicorp/go-plugin/runner" ) -// Options selects already verified executables. Env overrides inherited values; -// it must not contain an allowlist unless the caller deliberately wants one. +// Options selects already verified executables. Env overrides the environment +// selected by go-plugin. Restricting runtime inheritance requires the client +// to set SkipHostEnv; supplying a few Env entries alone does not restrict it. +// Client-assigned transport metadata takes precedence over Env overrides. type Options struct { SupervisorBinary string SupervisorArgs []string diff --git a/supervisor/runner_test.go b/supervisor/runner_test.go index 676cfc5..6185fbb 100644 --- a/supervisor/runner_test.go +++ b/supervisor/runner_test.go @@ -19,36 +19,51 @@ type inspection struct { Directory string } +const supervisorFixtureRole = "--helper-role=supervisor" + func TestMain(m *testing.M) { if len(os.Args) > 1 { - switch os.Args[1] { - case "--helper-role=supervisor": - Main(os.Args[2:]) - case "--helper-role=inspect": - directory, err := os.Getwd() - if err != nil { - panic(err) - } - err = json.NewEncoder(os.Stdout). - Encode(inspection{ - Args: argumentBytes(os.Args[2:]), - Env: []byte(os.Getenv("DEVSY_OWNERSHIP_TEST")), - Directory: directory, - }) - if err != nil { - panic(err) - } - if _, err := os.Stderr.Write( - bytes.Repeat([]byte("diagnostic tail\n"), 64), - ); err != nil { - panic(err) - } - os.Exit(0) - } + runFixtureRole(os.Args[1]) } os.Exit(m.Run()) } +func runFixtureRole(role string) { + switch role { + case supervisorFixtureRole: + Main(os.Args[2:]) + case "--helper-role=inspect": + inspectMain() + os.Exit(0) + case "--helper-role=environment": + serveEnvironment() + case "--helper-role=environment-child": + environmentChildMain() + os.Exit(0) + } +} + +func inspectMain() { + directory, err := os.Getwd() + if err != nil { + panic(err) + } + err = json.NewEncoder(os.Stdout). + Encode(inspection{ + Args: argumentBytes(os.Args[2:]), + Env: []byte(os.Getenv("DEVSY_OWNERSHIP_TEST")), + Directory: directory, + }) + if err != nil { + panic(err) + } + if _, err := os.Stderr.Write( + bytes.Repeat([]byte("diagnostic tail\n"), 64), + ); err != nil { + panic(err) + } +} + func shortDirectory(t *testing.T) string { t.Helper() dir, err := os.MkdirTemp("", "dso-") @@ -66,7 +81,7 @@ func fixtureOptions(t *testing.T) Options { t.Fatal(err) } return Options{ - SupervisorBinary: binary, SupervisorArgs: []string{"--helper-role=supervisor"}, + SupervisorBinary: binary, SupervisorArgs: []string{supervisorFixtureRole}, RuntimeBinary: binary, Args: []string{"--helper-role=inspect", argumentValue()}, Env: []string{"DEVSY_OWNERSHIP_TEST=" + environmentValue()}, Directory: t.TempDir(), }