From 61031b86bb295570c1032194be2de773d03c5cac Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:12:25 -0600 Subject: [PATCH 01/20] feat(env): add context-scoped environment bindings --- cmd/env/bind.go | 110 ++++++++++++++++++++++++++++++++++++++ cmd/env/bind_test.go | 79 +++++++++++++++++++++++++++ cmd/env/delete.go | 14 +++++ cmd/env/env.go | 2 + cmd/env/list.go | 30 ++++++++--- pkg/config/config.go | 4 ++ pkg/config/config_test.go | 22 ++++++++ 7 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 cmd/env/bind.go create mode 100644 cmd/env/bind_test.go diff --git a/cmd/env/bind.go b/cmd/env/bind.go new file mode 100644 index 000000000..d2d7727eb --- /dev/null +++ b/cmd/env/bind.go @@ -0,0 +1,110 @@ +package env + +import ( + "context" + "fmt" + "slices" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/spf13/cobra" +) + +type AttachCmd struct{ *flags.GlobalFlags } + +func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &AttachCmd{GlobalFlags: flags} + return &cobra.Command{ + Use: "attach NAME", + Short: "Bind a non-sensitive environment variable to the active context", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + return cmd.Run(c.Context(), args[0]) + }, + } +} + +func (cmd *AttachCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + if err := verifyAttachableEnv(devsyConfig, contextName, name); err != nil { + return err + } + if slices.Contains(ctxConfig.EnvVars, name) { + log.Infof("environment variable %q already attached to context %q", name, contextName) + return nil + } + ctxConfig.EnvVars = append(ctxConfig.EnvVars, name) + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("environment variable %q attached to context %q", name, contextName) + return nil +} + +func verifyAttachableEnv(devsyConfig *config.Config, contextName, name string) error { + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + meta, err := store.Meta(contextName, name) + if err != nil { + return fmt.Errorf("cannot attach environment variable %q: %w", name, err) + } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret\"", name) + } + return nil +} + +type DetachCmd struct{ *flags.GlobalFlags } + +func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command { + cmd := &DetachCmd{GlobalFlags: flags} + return &cobra.Command{ + Use: "detach NAME", + Short: "Unbind an environment variable from the active context", + Args: cobra.ExactArgs(1), + RunE: func(c *cobra.Command, args []string) error { + return cmd.Run(c.Context(), args[0]) + }, + } +} + +func (cmd *DetachCmd) Run(_ context.Context, name string) error { + if err := secrets.ValidateName(name); err != nil { + return err + } + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + ctxConfig := devsyConfig.Contexts[contextName] + if ctxConfig == nil { + return fmt.Errorf("context %q doesn't exist", contextName) + } + idx := slices.Index(ctxConfig.EnvVars, name) + if idx < 0 { + log.Infof("environment variable %q is not attached to context %q", name, contextName) + return nil + } + ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save config: %w", err) + } + log.Infof("environment variable %q detached from context %q", name, contextName) + return nil +} diff --git a/cmd/env/bind_test.go b/cmd/env/bind_test.go new file mode 100644 index 000000000..f090290e0 --- /dev/null +++ b/cmd/env/bind_test.go @@ -0,0 +1,79 @@ +package env + +import ( + "context" + "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +func setupEnvCommandTest(t *testing.T) *flags.GlobalFlags { + t.Helper() + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) + t.Setenv(config.EnvHome, t.TempDir()) + t.Setenv("DEVSY_SECRETS_BACKEND", "file") + t.Setenv("DEVSY_SECRETS_PASSPHRASE", "test-passphrase") + require.NoError(t, config.SaveConfig(&config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{config.DefaultContext: {}}, + })) + return &flags.GlobalFlags{} +} + +func TestAttachAndDetachEnvironmentVariable(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := &SetCmd{GlobalFlags: globalFlags, Value: "debug"} + require.NoError(t, set.Run(context.Background(), "LOG_LEVEL")) + + attach := &AttachCmd{GlobalFlags: globalFlags} + require.NoError(t, attach.Run(context.Background(), "LOG_LEVEL")) + require.NoError(t, attach.Run(context.Background(), "LOG_LEVEL")) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{"LOG_LEVEL"}, cfg.Current().EnvVars) + + detach := &DetachCmd{GlobalFlags: globalFlags} + require.NoError(t, detach.Run(context.Background(), "LOG_LEVEL")) + require.NoError(t, detach.Run(context.Background(), "LOG_LEVEL")) + cfg, err = config.LoadConfig("", "") + require.NoError(t, err) + require.Empty(t, cfg.Current().EnvVars) +} + +func TestAttachRejectsSecret(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + store, err := secrets.NewStoreForConfig(mustLoadConfig(t)) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "API_TOKEN", "secret", secrets.KindSecret)) + err = (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), "API_TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "is a secret") +} + +func TestDeleteUnbindsEnvironmentVariable(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := &SetCmd{GlobalFlags: globalFlags, Value: "debug"} + require.NoError(t, set.Run(context.Background(), "LOG_LEVEL")) + require.NoError( + t, + (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), "LOG_LEVEL"), + ) + require.NoError( + t, + (&DeleteCmd{GlobalFlags: globalFlags}).Run(context.Background(), "LOG_LEVEL"), + ) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.Empty(t, cfg.Current().EnvVars) +} + +func mustLoadConfig(t *testing.T) *config.Config { + t.Helper() + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + return cfg +} diff --git a/cmd/env/delete.go b/cmd/env/delete.go index 14bb9d348..2e983a488 100644 --- a/cmd/env/delete.go +++ b/cmd/env/delete.go @@ -3,8 +3,10 @@ package env import ( "context" "fmt" + "slices" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" "github.com/spf13/cobra" ) @@ -42,6 +44,18 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { if err := store.Delete(contextName, name); err != nil { return err } + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + if idx := slices.Index(ctxConfig.EnvVars, name); idx >= 0 { + ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) + if err := config.SaveConfig(devsyConfig); err != nil { + return err + } + } + } log.Infof("env var %q deleted from context %q", name, contextName) return nil } diff --git a/cmd/env/env.go b/cmd/env/env.go index 7d6389bb9..4bd7b20a0 100644 --- a/cmd/env/env.go +++ b/cmd/env/env.go @@ -19,6 +19,8 @@ use "devsy secret" for sensitive values.`, envCmd.AddCommand(NewListCmd(globalFlags)) envCmd.AddCommand(NewGetCmd(globalFlags)) envCmd.AddCommand(NewDeleteCmd(globalFlags)) + envCmd.AddCommand(NewAttachCmd(globalFlags)) + envCmd.AddCommand(NewDetachCmd(globalFlags)) return envCmd } diff --git a/cmd/env/list.go b/cmd/env/list.go index 5ff41d23e..aee808b60 100644 --- a/cmd/env/list.go +++ b/cmd/env/list.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "slices" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -30,12 +33,19 @@ func NewListCmd(flags *flags.GlobalFlags) *cobra.Command { } type envEntry struct { - Name string `json:"name"` - Value string `json:"value"` + Name string `json:"name"` + Value string `json:"value"` + Context string `json:"context"` + Attached bool `json:"attached"` } func (cmd *ListCmd) Run(_ context.Context) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) + devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) if err != nil { return err } @@ -49,7 +59,15 @@ func (cmd *ListCmd) Run(_ context.Context) error { if m.Sensitive() { continue } - entries = append(entries, envEntry{Name: m.Name, Value: m.Value}) + entries = append( + entries, + envEntry{ + Name: m.Name, + Value: m.Value, + Context: contextName, + Attached: slices.Contains(devsyConfig.Contexts[contextName].EnvVars, m.Name), + }, + ) } mode, err := output.ResolveMode(cmd.ResultFormat) @@ -76,7 +94,7 @@ func renderJSON(entries []envEntry) error { func renderPlain(entries []envEntry) { rows := make([][]string, 0, len(entries)) for _, e := range entries { - rows = append(rows, []string{e.Name, e.Value}) + rows = append(rows, []string{e.Name, e.Value, e.Context, fmt.Sprint(e.Attached)}) } - table.Print([]string{"Name", "Value"}, rows) + table.Print([]string{"Name", "Value", "Context", "Attached"}, rows) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d5dc7f0da..29da57d8d 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,6 +50,10 @@ type ContextConfig struct { // Secrets are names of stored secrets bound to this context, injected on `up`. Names only, no values. Secrets []string `json:"secrets,omitempty"` + // EnvVars are names of stored non-sensitive environment variables bound to + // this context, injected on `up`. Names only, no values. + EnvVars []string `json:"envVars,omitempty"` + // OriginalProvider is the original default provider OriginalProvider string `json:"-"` } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 7e1daed56..00dd0d3f5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -65,3 +65,25 @@ func TestLoadConfig_StampsMissingSchemaVersionOnExistingConfig(t *testing.T) { ) } } + +func TestContextConfigEnvVarsRoundTrip(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{ + DefaultContext: {EnvVars: []string{"LOG_LEVEL"}}, + }, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if len(got.Current().EnvVars) != 1 || got.Current().EnvVars[0] != "LOG_LEVEL" { + t.Fatalf("EnvVars = %#v, want [LOG_LEVEL]", got.Current().EnvVars) + } +} From 5119873264adfdb906dc3704f61d61a5c18e6261 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:12:31 -0600 Subject: [PATCH 02/20] feat(workspace): inject attached managed environment variables --- cmd/workspace/up/secrets_test.go | 61 ++++++++++++- cmd/workspace/up/up_client.go | 141 ++++++++++++++++++++++++------- 2 files changed, 169 insertions(+), 33 deletions(-) diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index ada71fdee..5bab91ce3 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -27,6 +27,13 @@ func testConfig(bound ...string) *config.Config { } } +func testEnvConfig(bound ...string) *config.Config { + return &config.Config{ + DefaultContext: "default", + Contexts: map[string]*config.ContextConfig{"default": {EnvVars: bound}}, + } +} + func localRef(name string) secretspkg.SecretRef { return secretspkg.SecretRef{ Type: secretspkg.LocalSourceName, @@ -129,6 +136,56 @@ func TestCollectSecretRequests_DuplicateMountTargetRejected(t *testing.T) { assert.Contains(t, err.Error(), "both mount to target") } +func TestCollectEnvVarRequests_ContextAttachment(t *testing.T) { + got, err := collectEnvVarRequests(nil, testEnvConfig("ZED", "ALPHA")) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("ALPHA"), target: "ALPHA"}, + {ref: localRef("ZED"), target: "ZED"}, + }, got) +} + +func TestCollectEnvVarRequests_ExplicitOverridesAttachment(t *testing.T) { + got, err := collectEnvVarRequests([]string{"LOG_LEVEL=APP_LOG"}, testEnvConfig("LOG_LEVEL")) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{{ref: localRef("LOG_LEVEL"), target: "APP_LOG"}}, got) +} + +func TestCollectEnvVarRequests_DuplicateTargetRejected(t *testing.T) { + requests, err := collectEnvVarRequests( + []string{"FOO=APP_MODE", "BAR=APP_MODE"}, + testEnvConfig(), + ) + if err == nil { + err = checkDuplicateEnvTargets(requests) + } + require.Error(t, err) + assert.Contains(t, err.Error(), `both target "APP_MODE"`) +} + +func TestCollectEnvVarRequests_InvalidAttachedReference(t *testing.T) { + _, err := collectEnvVarRequests(nil, testEnvConfig("sops:project/FOO")) + require.Error(t, err) + assert.Contains(t, err.Error(), "must use the local Devsy store") +} + +func TestApplyEnvVars_ContextAttachedNonSensitive(t *testing.T) { + cmd := &UpCmd{} + resolver := secretspkg.NewResolver() + require.NoError(t, resolver.Register("local", "local", fixedSource{ + values: map[string]string{"LOG_LEVEL": "debug"}, sensitive: false, + })) + + err := cmd.applyEnvVars(context.Background(), testEnvConfig("LOG_LEVEL"), resolver) + require.NoError(t, err) + assert.Equal(t, []string{"LOG_LEVEL=debug"}, cmd.WorkspaceEnv) +} + +func TestHasStoredValues_AttachedEnvVars(t *testing.T) { + cmd := &UpCmd{} + assert.True(t, cmd.hasStoredValues(nil, testEnvConfig("LOG_LEVEL"))) +} + type fixedSource struct { values map[string]string sensitive bool @@ -146,7 +203,7 @@ func TestApplyEnvVars_RejectsSensitiveSecret(t *testing.T) { values: map[string]string{secretAPIKey: "plaintext"}, sensitive: true, })) - err := cmd.applyEnvVars(context.Background(), resolver) + err := cmd.applyEnvVars(context.Background(), testConfig(), resolver) require.Error(t, err) assert.Contains(t, err.Error(), "is a secret and cannot be passed with --env") assert.Empty(t, cmd.WorkspaceEnv, "no value must reach the ps-visible WorkspaceEnv") @@ -160,7 +217,7 @@ func TestApplyEnvVars_AllowsNonSensitive(t *testing.T) { values: map[string]string{"LOG_LEVEL": "debug"}, sensitive: false, })) - err := cmd.applyEnvVars(context.Background(), resolver) + err := cmd.applyEnvVars(context.Background(), testConfig(), resolver) require.NoError(t, err) assert.Equal(t, []string{"DEBUG_TARGET=debug"}, cmd.WorkspaceEnv) } diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 6da5f81c5..2a23a833b 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -301,7 +301,7 @@ func (cmd *UpCmd) resolveStoredSecrets( if err != nil { return err } - if !cmd.hasStoredValues(requests) { + if !cmd.hasStoredValues(requests, devsyConfig) { return nil } @@ -316,14 +316,19 @@ func (cmd *UpCmd) resolveStoredSecrets( if err := cmd.applyLifecycleSecrets(ctx, requests, resolver); err != nil { return err } - if err := cmd.applyEnvVars(ctx, resolver); err != nil { + if err := cmd.applyEnvVars(ctx, devsyConfig, resolver); err != nil { return err } return cmd.applyBuildSecrets(ctx, resolver) } -func (cmd *UpCmd) hasStoredValues(requests []secretRequest) bool { - return len(requests) > 0 || len(cmd.EnvVars) > 0 || len(cmd.BuildSecretNames) > 0 +func (cmd *UpCmd) hasStoredValues(requests []secretRequest, devsyConfig *config.Config) bool { + attachedEnvCount := 0 + if current := devsyConfig.Current(); current != nil { + attachedEnvCount = len(current.EnvVars) + } + return len(requests) > 0 || len(cmd.EnvVars) > 0 || attachedEnvCount > 0 || + len(cmd.BuildSecretNames) > 0 } func (cmd *UpCmd) applyLifecycleSecrets( @@ -345,55 +350,129 @@ func (cmd *UpCmd) applyLifecycleSecrets( return nil } -// applyEnvVars resolves --env entries from the local store. External -// sensitive sources intentionally use --secret instead: WorkspaceEnv rides in -// the setup argv and is process-list visible. -func (cmd *UpCmd) applyEnvVars(ctx context.Context, resolver *secrets.Resolver) error { - for _, entry := range cmd.EnvVars { - envVar, err := resolveEnvVarEntry(ctx, resolver, entry) +type envVarRequest struct { + ref secrets.SecretRef + target string +} + +func collectEnvVarRequests(flags []string, devsyConfig *config.Config) ([]envVarRequest, error) { + byRef := map[string]envVarRequest{} + if current := devsyConfig.Current(); current != nil { + for _, name := range current.EnvVars { + ref, err := secrets.ParseRef(name) + if err != nil { + return nil, fmt.Errorf("invalid attached environment variable %q: %w", name, err) + } + if ref.Source != secrets.LocalSourceName { + return nil, fmt.Errorf( + "attached environment variable %q must use the local Devsy store", + name, + ) + } + byRef[ref.String()] = envVarRequest{ref: ref, target: ref.Name} + } + } + for _, entry := range flags { + req, err := parseEnvVarRequest(entry) if err != nil { - return err + return nil, err } - cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, envVar) + byRef[req.ref.String()] = req } - return nil + requests := make([]envVarRequest, 0, len(byRef)) + for _, req := range byRef { + requests = append(requests, req) + } + sort.Slice( + requests, + func(i, j int) bool { return requests[i].ref.String() < requests[j].ref.String() }, + ) + return requests, nil } -func resolveEnvVarEntry( - ctx context.Context, - resolver *secrets.Resolver, - entry string, -) (string, error) { - name, explicitTarget, hasTarget := strings.Cut(entry, "=") - if hasTarget && explicitTarget == "" { - return "", fmt.Errorf("invalid --env %q: target after %q= must not be empty", entry, name) +func parseEnvVarRequest(entry string) (envVarRequest, error) { + name, target, hasTarget := strings.Cut(entry, "=") + if hasTarget && target == "" { + return envVarRequest{}, fmt.Errorf( + "invalid --env %q: target after %q= must not be empty", + entry, + name, + ) } ref, err := secrets.ParseRef(name) if err != nil { - return "", err - } - target := ref.Name - if hasTarget { - target = explicitTarget + return envVarRequest{}, err } if ref.Source != secrets.LocalSourceName { - return "", fmt.Errorf( + return envVarRequest{}, fmt.Errorf( "--env only accepts Devsy-managed values; use --secret %s instead", name, ) } - resolved, err := resolver.Resolve(ctx, ref) + if !hasTarget { + target = ref.Name + } + return envVarRequest{ref: ref, target: target}, nil +} + +func checkDuplicateEnvTargets(requests []envVarRequest) error { + targets := map[string]secrets.SecretRef{} + for _, req := range requests { + if previous, ok := targets[req.target]; ok && previous.String() != req.ref.String() { + return fmt.Errorf( + "environment variables %q and %q both target %q", + previous.Name, + req.ref.Name, + req.target, + ) + } + targets[req.target] = req.ref + } + return nil +} + +// applyEnvVars resolves attached and explicit entries from the local store. External +// sensitive sources intentionally use --secret instead: WorkspaceEnv rides in +// the setup argv and is process-list visible. +func (cmd *UpCmd) applyEnvVars( + ctx context.Context, + devsyConfig *config.Config, + resolver *secrets.Resolver, +) error { + requests, err := collectEnvVarRequests(cmd.EnvVars, devsyConfig) + if err != nil { + return err + } + if err := checkDuplicateEnvTargets(requests); err != nil { + return err + } + for _, req := range requests { + envVar, err := resolveEnvVarRequest(ctx, resolver, req) + if err != nil { + return err + } + cmd.WorkspaceEnv = append(cmd.WorkspaceEnv, envVar) + } + return nil +} + +func resolveEnvVarRequest( + ctx context.Context, + resolver *secrets.Resolver, + req envVarRequest, +) (string, error) { + resolved, err := resolver.Resolve(ctx, req.ref) if err != nil { return "", err } if resolved.Sensitive { return "", fmt.Errorf( "%q is a secret and cannot be passed with --env (it would be visible in the process list); use --secret %s instead", - name, - name, + req.ref.Name, + req.ref.Name, ) } - return target + "=" + resolved.Value, nil + return req.target + "=" + resolved.Value, nil } func (cmd *UpCmd) applyBuildSecrets(ctx context.Context, resolver *secrets.Resolver) error { From c96af033dcbb795fba818045885728d9ac9152c2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:12:36 -0600 Subject: [PATCH 03/20] feat(desktop): manage workspace environment attachments --- .../main/__tests__/ipc-provider-jobs.test.ts | 8 +++ desktop/src/main/ipc.ts | 26 ++++++++ .../src/renderer/src/lib/ipc/commands.test.ts | 20 +++++++ desktop/src/renderer/src/lib/ipc/commands.ts | 8 +++ desktop/src/renderer/src/lib/types/index.ts | 2 + desktop/src/renderer/src/pages/EnvPage.svelte | 43 +++++++++++-- .../src/renderer/src/pages/EnvPage.test.ts | 60 +++++++++++++++++++ 7 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 desktop/src/renderer/src/pages/EnvPage.test.ts diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index dfeda0a99..09f5322d2 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -99,6 +99,14 @@ describe("provider job lifecycle over IPC", () => { vi.clearAllMocks() }) + it("forwards managed environment attachment intent to the CLI", async () => { + const { cli } = setup() + await invoke("env_attach", { name: "LOG_LEVEL" }) + await invoke("env_detach", { name: "LOG_LEVEL" }) + expect(cli.runRaw).toHaveBeenCalledWith(["env", "attach", "LOG_LEVEL"]) + expect(cli.runRaw).toHaveBeenCalledWith(["env", "detach", "LOG_LEVEL"]) + }) + it("clears the job when init succeeds", async () => { const { providerJobs } = setup(() => ({ lines: [statusLine("running_init"), statusLine("ready")], diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 9c92432fa..23ff405fd 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -53,6 +53,8 @@ interface SecretEntry { interface EnvEntry { name: string value: string + context: string + attached: boolean } function dockerArch(nodeArch: string): string { @@ -1146,6 +1148,30 @@ export function registerIpcHandlers(deps: IpcDependencies): { } }) + ipcMain.handle("env_attach", async (_event, args: { name: string }) => { + trackEvent("env_attach") + try { + await cli.runRaw(["env", "attach", args.name]) + return { ok: true } as const + } catch (err) { + const cliError = (err as { cliError?: CLIError }).cliError + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message, cliError } as const + } + }) + + ipcMain.handle("env_detach", async (_event, args: { name: string }) => { + trackEvent("env_detach") + try { + await cli.runRaw(["env", "detach", args.name]) + return { ok: true } as const + } catch (err) { + const cliError = (err as { cliError?: CLIError }).cliError + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message, cliError } as const + } + }) + // ── System ── ipcMain.handle("devsy_version", async () => { return cli.runRaw(["--version"]) diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index a8b5e9aa1..6c87e3ab9 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -7,6 +7,8 @@ import { auditRecent, contextUse, devsyVersion, + envAttach, + envDetach, machineCreate, machineDelete, machineStatus, @@ -142,6 +144,24 @@ describe("IPC commands", () => { }) }) + describe("managed environment commands", () => { + it("envAttach sends only the variable name", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envAttach("LOG_LEVEL") + expect(mockInvoke).toHaveBeenCalledWith("env_attach", { + name: "LOG_LEVEL", + }) + }) + + it("envDetach sends only the variable name", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envDetach("LOG_LEVEL") + expect(mockInvoke).toHaveBeenCalledWith("env_detach", { + name: "LOG_LEVEL", + }) + }) + }) + describe("provider commands", () => { it("providerAdd passes name and source", async () => { await providerAdd("docker", "https://example.com/provider") diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 24cd60ff4..4743a20cd 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -341,6 +341,14 @@ export async function envDelete(name: string): Promise { unwrapEnvelope(await invoke("env_delete", { name })) } +export async function envAttach(name: string): Promise { + unwrapEnvelope(await invoke("env_attach", { name })) +} + +export async function envDetach(name: string): Promise { + unwrapEnvelope(await invoke("env_detach", { name })) +} + // Audit commands export async function auditRecent(limit?: number): Promise { return invoke("audit_recent", { limit }) diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 5f1341a75..3632667a9 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -161,6 +161,8 @@ export interface Secret { export interface EnvVar { name: string value: string + context: string + attached: boolean } export interface CommandProgress { diff --git a/desktop/src/renderer/src/pages/EnvPage.svelte b/desktop/src/renderer/src/pages/EnvPage.svelte index 8a2fccfe1..6c1032596 100644 --- a/desktop/src/renderer/src/pages/EnvPage.svelte +++ b/desktop/src/renderer/src/pages/EnvPage.svelte @@ -3,13 +3,15 @@ import { Braces, Eye, EyeOff, Plus, Search, Trash2 } from "@lucide/svelte" import { Button } from "$lib/components/ui/button/index.js" import { Input } from "$lib/components/ui/input/index.js" import { Label } from "$lib/components/ui/label/index.js" +import { Switch } from "$lib/components/ui/switch/index.js" import * as Dialog from "$lib/components/ui/dialog/index.js" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" import CardSkeleton from "$lib/components/ui/skeleton/CardSkeleton.svelte" import { envVars, envLoading, refreshEnv } from "$lib/stores/env.js" -import { envSet, envDelete } from "$lib/ipc/commands.js" +import { envSet, envDelete, envAttach, envDetach } from "$lib/ipc/commands.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" +import type { EnvVar } from "$lib/types/index.js" const NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/ @@ -22,6 +24,7 @@ let revealed = $state>({}) let confirmDeleteOpen = $state(false) let pendingDelete = $state("") let deleting = $state(false) +let updatingAttachment = $state>({}) let searchTerm = $state("") let filteredEnvVars = $derived.by(() => { @@ -51,7 +54,9 @@ async function handleCreate() { try { await envSet(name, newValue) } catch (err) { - toasts.error(`Failed to save environment variable: ${extractErrorMessage(err)}`) + toasts.error( + `Failed to save environment variable: ${extractErrorMessage(err)}`, + ) saving = false return } @@ -77,7 +82,9 @@ async function confirmDelete() { try { await envDelete(name) } catch (err) { - toasts.error(`Failed to delete environment variable: ${extractErrorMessage(err)}`) + toasts.error( + `Failed to delete environment variable: ${extractErrorMessage(err)}`, + ) deleting = false return } @@ -87,6 +94,22 @@ async function confirmDelete() { await refreshEnv().catch(() => {}) } +async function setAttached(envVar: EnvVar, attached: boolean) { + if (updatingAttachment[envVar.name]) return + updatingAttachment = { ...updatingAttachment, [envVar.name]: true } + try { + if (attached) await envAttach(envVar.name) + else await envDetach(envVar.name) + await refreshEnv() + } catch (err) { + toasts.error( + `Failed to ${attached ? "attach" : "detach"} environment variable: ${extractErrorMessage(err)}`, + ) + } finally { + updatingAttachment = { ...updatingAttachment, [envVar.name]: false } + } +} + function toggleReveal(name: string) { revealed = { ...revealed, [name]: !revealed[name] } } @@ -110,7 +133,7 @@ function toggleReveal(name: string) { {nameExists ? "Update Environment Variable" : "Add Environment Variable"} - Stored in plaintext in your Devsy config and injected into workspaces. + Stored in plaintext in your current Devsy context. Enable "Inject into workspaces" to add it automatically when workspaces start.
{ e.preventDefault(); handleCreate() }} class="space-y-4"> @@ -211,6 +234,18 @@ function toggleReveal(name: string) {

{revealed[envVar.name] ? envVar.value : "••••••••"}

+
+
+

Inject into workspaces

+

Context: {envVar.context}. Applies when a workspace starts or is recreated.

+
+ setAttached(envVar, checked)} + /> +
{/each} diff --git a/desktop/src/renderer/src/pages/EnvPage.test.ts b/desktop/src/renderer/src/pages/EnvPage.test.ts new file mode 100644 index 000000000..7f3537322 --- /dev/null +++ b/desktop/src/renderer/src/pages/EnvPage.test.ts @@ -0,0 +1,60 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const mocks = vi.hoisted(() => ({ + envAttach: vi.fn().mockResolvedValue(undefined), + envDetach: vi.fn().mockResolvedValue(undefined), + refreshEnv: vi.fn().mockResolvedValue(undefined), +})) +vi.mock("$lib/ipc/commands.js", () => ({ + envAttach: mocks.envAttach, + envDelete: vi.fn(), + envDetach: mocks.envDetach, + envSet: vi.fn(), +})) +vi.mock("$lib/stores/env.js", async () => { + const { writable } = await import("svelte/store") + return { + envLoading: writable(false), + envVars: writable([ + { name: "ATTACHED", value: "one", context: "default", attached: true }, + { name: "DETACHED", value: "two", context: "default", attached: false }, + ]), + refreshEnv: mocks.refreshEnv, + } +}) + +import EnvPage from "./EnvPage.svelte" + +describe("EnvPage managed environment attachments", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders attachment state and sends attach/detach intent", async () => { + render(EnvPage) + + expect( + screen + .getByRole("switch", { name: "Inject ATTACHED into workspaces" }) + .getAttribute("aria-checked"), + ).toBe("true") + const detached = screen.getByRole("switch", { + name: "Inject DETACHED into workspaces", + }) + expect(detached.getAttribute("aria-checked")).toBe("false") + + await fireEvent.click(detached) + await waitFor(() => + expect(mocks.envAttach).toHaveBeenCalledWith("DETACHED"), + ) + expect(mocks.refreshEnv).toHaveBeenCalled() + + await fireEvent.click( + screen.getByRole("switch", { name: "Inject ATTACHED into workspaces" }), + ) + await waitFor(() => + expect(mocks.envDetach).toHaveBeenCalledWith("ATTACHED"), + ) + }) +}) From e94e101c1d9a361f087dac3d393f5607144c059a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:12:41 -0600 Subject: [PATCH 04/20] test(env): cover automatic managed environment injection --- e2e/tests/up/helper.go | 3 +- e2e/tests/up/provider_docker.go | 38 +++++++++++++++++++ .../.devcontainer.json | 4 ++ .../docs/developing-in-workspaces/secrets.mdx | 21 ++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index 89f85f715..b2ec878c5 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -21,6 +21,7 @@ import ( const ( secretCmd = "secret" + envCmd = "env" cmdSSH = "ssh" flagCommand = "--command" sshProbeTimeout = 20 * time.Second @@ -54,7 +55,7 @@ func (dtc *dockerTestContext) storeSecret(ctx context.Context, name, value strin } func (dtc *dockerTestContext) storeEnv(ctx context.Context, name, value string) { - _, err := dtc.f.ExecCommandOutput(ctx, []string{"env", "set", name, "--value", value}) + _, err := dtc.f.ExecCommandOutput(ctx, []string{envCmd, "set", name, "--value", value}) framework.ExpectNoError(err) ginkgo.DeferCleanup(func() { _, _ = dtc.f.ExecCommandOutput(ctx, []string{"env", "delete", name}) diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 346b71a5f..d17d4053a 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -781,6 +781,44 @@ var _ = ginkgo.Describe( ginkgo.SpecTimeout(framework.TimeoutShort()), ) + ginkgo.It( + "context-attached managed env var injects without --env and is removed after detach", + func(ctx context.Context) { + useFileSecretsBackend() + contextName := fmt.Sprintf("managed-env-%d", time.Now().UnixNano()) + framework.ExpectNoError(dtc.f.DevsyContextCreate(ctx, contextName)) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = dtc.f.DevsyContextUse(cleanupCtx, "default") + _ = dtc.f.DevsyContextDelete(cleanupCtx, contextName) + }) + framework.ExpectNoError(dtc.f.DevsyContextUse(ctx, contextName)) + + tempDir, err := setupWorkspace( + "tests/up/testdata/docker-managed-env-attached", + dtc.initialDir, + dtc.f, + ) + framework.ExpectNoError(err) + dtc.storeEnv(ctx, "ATTACHED_ENV", "expected-value") + _, err = dtc.f.ExecCommandOutput(ctx, []string{envCmd, "attach", "ATTACHED_ENV"}) + framework.ExpectNoError(err) + + // Intentionally no --env argument: the context binding is the source. + framework.ExpectNoError(dtc.f.DevsyUp(ctx, tempDir)) + out, err := dtc.execSSH(ctx, tempDir, "cat /tmp/attached-env-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("expected-value")) + + _, err = dtc.f.ExecCommandOutput(ctx, []string{envCmd, "detach", "ATTACHED_ENV"}) + framework.ExpectNoError(err) + framework.ExpectNoError(dtc.f.DevsyUpRecreate(ctx, tempDir)) + out, err = dtc.execSSH(ctx, tempDir, "cat /tmp/attached-env-check.out") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.BeEmpty()) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) + ginkgo.It("multi devcontainer selection", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-multi-devcontainer", diff --git a/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json b/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json new file mode 100644 index 000000000..3e393d847 --- /dev/null +++ b/e2e/tests/up/testdata/docker-managed-env-attached/.devcontainer.json @@ -0,0 +1,4 @@ +{ + "image": "ghcr.io/devsy-org/test-images/go:1", + "postCreateCommand": "printf '%s' \"${ATTACHED_ENV-}\" > /tmp/attached-env-check.out" +} diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 93b08a3c2..342b5f259 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -350,3 +350,24 @@ devsy workspace up ... --env LOG_LEVEL --env REGION=AWS_REGION Use `devsy secret` or an external secret source for sensitive values. `--env` is deliberately restricted to non-sensitive Devsy-managed values because that path is not the protected secret-delivery channel. + +Attach a stored variable to the active context to inject it automatically when +workspaces start or are recreated: + +```shell +devsy env attach LOG_LEVEL +devsy env list +devsy env detach LOG_LEVEL +``` + +Attachments are context-scoped and contain only the variable name. The Desktop +Environment Variables page shows and changes the same attachment state. Managed +environment variables are non-sensitive plaintext values; use `devsy secret` +for secrets. + +An explicit `devsy workspace up --env LOG_LEVEL` still works for one invocation. +An explicit target, such as `--env LOG_LEVEL=APP_LOG_LEVEL`, overrides the +automatic attachment for that reference and injects the stored value as +`APP_LOG_LEVEL`. Attaching or detaching does not mutate an already-running +workspace; apply changes through the normal workspace start or recreate +lifecycle. From 32c3c4f7bacb50d38f50bb789b82570dbe650692 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:19:11 -0600 Subject: [PATCH 05/20] fix(env): guard attachment metadata lookup --- cmd/env/list.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/env/list.go b/cmd/env/list.go index aee808b60..e41a613fe 100644 --- a/cmd/env/list.go +++ b/cmd/env/list.go @@ -53,6 +53,10 @@ func (cmd *ListCmd) Run(_ context.Context) error { if err != nil { return err } + var attachedNames []string + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + attachedNames = ctxConfig.EnvVars + } entries := make([]envEntry, 0, len(metas)) for _, m := range metas { @@ -65,7 +69,7 @@ func (cmd *ListCmd) Run(_ context.Context) error { Name: m.Name, Value: m.Value, Context: contextName, - Attached: slices.Contains(devsyConfig.Contexts[contextName].EnvVars, m.Name), + Attached: slices.Contains(attachedNames, m.Name), }, ) } From 744317f2dc1965d9fa6f256f8246c019eb408c1e Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 23 Sep 2026 23:22:31 -0600 Subject: [PATCH 06/20] fix(workspace): reject ambiguous env targets --- cmd/workspace/up/secrets_test.go | 6 ++++++ cmd/workspace/up/up_client.go | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 5bab91ce3..36cf34118 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -169,6 +169,12 @@ func TestCollectEnvVarRequests_InvalidAttachedReference(t *testing.T) { assert.Contains(t, err.Error(), "must use the local Devsy store") } +func TestCollectEnvVarRequests_RejectsEqualsInTarget(t *testing.T) { + _, err := collectEnvVarRequests([]string{"FOO=APP=MODE"}, testEnvConfig()) + require.Error(t, err) + assert.Contains(t, err.Error(), "must not contain =") +} + func TestApplyEnvVars_ContextAttachedNonSensitive(t *testing.T) { cmd := &UpCmd{} resolver := secretspkg.NewResolver() diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 2a23a833b..87d370fb6 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -399,6 +399,13 @@ func parseEnvVarRequest(entry string) (envVarRequest, error) { name, ) } + if hasTarget && strings.Contains(target, "=") { + return envVarRequest{}, fmt.Errorf( + "invalid --env %q: target %q must not contain =", + entry, + target, + ) + } ref, err := secrets.ParseRef(name) if err != nil { return envVarRequest{}, err From 788347d41503d3b662cf5ff1af4332771f538461 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:31:19 -0600 Subject: [PATCH 07/20] fix(e2e): configure docker in managed env context --- e2e/tests/up/provider_docker.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index d17d4053a..aee7fedb0 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -792,6 +792,15 @@ var _ = ginkgo.Describe( _ = dtc.f.DevsyContextDelete(cleanupCtx, contextName) }) framework.ExpectNoError(dtc.f.DevsyContextUse(ctx, contextName)) + framework.ExpectNoError( + dtc.f.DevsyProviderAdd( + ctx, + "docker", + "-o", + "DOCKER_PATH=docker", + ), + ) + framework.ExpectNoError(dtc.f.DevsyProviderUse(ctx, "docker")) tempDir, err := setupWorkspace( "tests/up/testdata/docker-managed-env-attached", From 27a86572358731b473eb0542cec9e317e170f2af Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:55:55 -0600 Subject: [PATCH 08/20] fix(config): serialize and atomically persist mutations --- pkg/config/config.go | 62 +++++++++++++++++++++++++++++++++++---- pkg/config/config_test.go | 39 ++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 29da57d8d..f3290ea8a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,6 +10,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/types" + "github.com/gofrs/flock" "sigs.k8s.io/yaml" ) @@ -327,12 +328,13 @@ func SaveConfig(config *Config) error { } config = CloneConfig(config) + selectedContext := config.DefaultContext + if selected := config.Contexts[selectedContext]; selected != nil && selected.OriginalProvider != "" { + selected.DefaultProvider = selected.OriginalProvider + } if config.OriginalContext != "" { config.DefaultContext = config.OriginalContext } - if config.Contexts[config.DefaultContext].OriginalProvider != "" { - config.Contexts[config.DefaultContext].DefaultProvider = config.Contexts[config.DefaultContext].OriginalProvider - } out, err := yaml.Marshal(config) if err != nil { @@ -344,11 +346,61 @@ func SaveConfig(config *Config) error { return err } - err = os.WriteFile(configOrigin, out, 0o600) + return writeConfigAtomic(configOrigin, out) +} + +// LockConfig serializes read/modify/write mutations to config.yaml across +// processes. Callers must acquire it before loading the config and hold it +// until every related persistent operation is complete. +func LockConfig() (func(), error) { + configPath, err := GetConfigPath() if err != nil { - return err + return nil, err } + if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil { + return nil, err + } + lock := flock.New(configPath + ".lock") + if err := lock.Lock(); err != nil { + return nil, fmt.Errorf("lock config %q: %w", configPath+".lock", err) + } + return func() { _ = lock.Unlock() }, nil +} +func writeConfigAtomic(configOrigin string, data []byte) error { + dir := filepath.Dir(configOrigin) + tmp, err := os.CreateTemp(dir, filepath.Base(configOrigin)+".tmp-*") + if err != nil { + return err + } + tmpName := tmp.Name() + success := false + defer func() { + _ = tmp.Close() + if !success { + _ = os.Remove(tmpName) + } + }() + if err := tmp.Chmod(0o600); err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + if err := os.Rename(tmpName, configOrigin); err != nil { + return err + } + success = true + if dirFile, err := os.Open(dir); err == nil { + _ = dirFile.Sync() + _ = dirFile.Close() + } return nil } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 00dd0d3f5..6b80d915f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -87,3 +87,42 @@ func TestContextConfigEnvVarsRoundTrip(t *testing.T) { t.Fatalf("EnvVars = %#v, want [LOG_LEVEL]", got.Current().EnvVars) } } + +func TestSaveConfigRestoresTemporaryContextAndProviderOverrides(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{ + DefaultContext: {DefaultProvider: "docker"}, + "staging": {DefaultProvider: "kubernetes"}, + }, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + + loaded, err := LoadConfig("staging", "ssh") + if err != nil { + t.Fatal(err) + } + loaded.Current().EnvVars = []string{"LOG_LEVEL"} + if err := SaveConfig(loaded); err != nil { + t.Fatal(err) + } + + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if got.DefaultContext != DefaultContext { + t.Fatalf("DefaultContext = %q, want %q", got.DefaultContext, DefaultContext) + } + if got.Contexts["staging"].DefaultProvider != "kubernetes" { + t.Fatalf("staging provider = %q, want kubernetes", got.Contexts["staging"].DefaultProvider) + } + if got.Contexts["staging"].EnvVars[0] != "LOG_LEVEL" { + t.Fatalf("staging EnvVars = %#v", got.Contexts["staging"].EnvVars) + } +} From 55224b0f3c128e89639da15ddb86508bbb07f77b Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:56:00 -0600 Subject: [PATCH 09/20] fix(env): preserve attachment invariants --- cmd/env/bind.go | 14 ++++++++++-- cmd/env/bind_test.go | 37 ++++++++++++++++++++++++++++++++ cmd/env/delete.go | 24 +++++++++++++++------ cmd/env/set.go | 23 +++++++++++++++++++- cmd/secrets/bind.go | 14 ++++++++++-- cmd/secrets/delete.go | 23 +++++++++++++------- cmd/secrets/set.go | 23 +++++++++++++++++++- cmd/secrets/set_internal_test.go | 36 +++++++++++++++++++++++++++++++ 8 files changed, 173 insertions(+), 21 deletions(-) diff --git a/cmd/env/bind.go b/cmd/env/bind.go index d2d7727eb..ac543ec2f 100644 --- a/cmd/env/bind.go +++ b/cmd/env/bind.go @@ -30,7 +30,12 @@ func (cmd *AttachCmd) Run(_ context.Context, name string) error { if err := secrets.ValidateName(name); err != nil { return err } - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } @@ -87,7 +92,12 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error { if err := secrets.ValidateName(name); err != nil { return err } - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } diff --git a/cmd/env/bind_test.go b/cmd/env/bind_test.go index f090290e0..5b7fcef2d 100644 --- a/cmd/env/bind_test.go +++ b/cmd/env/bind_test.go @@ -2,6 +2,7 @@ package env import ( "context" + "sync" "testing" "github.com/devsy-org/devsy/cmd/flags" @@ -71,6 +72,42 @@ func TestDeleteUnbindsEnvironmentVariable(t *testing.T) { require.Empty(t, cfg.Current().EnvVars) } +func TestSetRejectsConvertingAttachedSecretToEnvironment(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + cfg := mustLoadConfig(t) + store, err := secrets.NewStoreForConfig(cfg) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "TOKEN", "secret", secrets.KindSecret)) + cfg.Current().Secrets = []string{"TOKEN"} + require.NoError(t, config.SaveConfig(cfg)) + err = (&SetCmd{GlobalFlags: globalFlags, Value: "plain"}).Run(context.Background(), "TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "attached as a secret") +} + +func TestConcurrentAttachmentsPreserveBothChanges(t *testing.T) { + globalFlags := setupEnvCommandTest(t) + set := func(name string) { + require.NoError(t, (&SetCmd{GlobalFlags: globalFlags, Value: name}).Run(context.Background(), name)) + } + set("FIRST") + set("SECOND") + + var wg sync.WaitGroup + for _, name := range []string{"FIRST", "SECOND"} { + wg.Add(1) + go func(name string) { + defer wg.Done() + require.NoError(t, (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), name)) + }(name) + } + wg.Wait() + + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + require.ElementsMatch(t, []string{"FIRST", "SECOND"}, cfg.Current().EnvVars) +} + func mustLoadConfig(t *testing.T) *config.Config { t.Helper() cfg, err := config.LoadConfig("", "") diff --git a/cmd/env/delete.go b/cmd/env/delete.go index 2e983a488..38b895067 100644 --- a/cmd/env/delete.go +++ b/cmd/env/delete.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -30,24 +31,30 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { } func (cmd *DeleteCmd) Run(_ context.Context, name string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) - if err != nil { + if err := secrets.ValidateName(name); err != nil { return err } - meta, err := store.Meta(contextName, name) + unlock, err := config.LockConfig() if err != nil { return err } - if meta.Sensitive() { - return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err } - if err := store.Delete(contextName, name); err != nil { + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { return err } - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + meta, err := store.Meta(contextName, name) if err != nil { return err } + if meta.Sensitive() { + return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) + } if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { if idx := slices.Index(ctxConfig.EnvVars, name); idx >= 0 { ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) @@ -56,6 +63,9 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { } } } + if err := store.Delete(contextName, name); err != nil { + return err + } log.Infof("env var %q deleted from context %q", name, contextName) return nil } diff --git a/cmd/env/set.go b/cmd/env/set.go index 05c2ffbee..173e93934 100644 --- a/cmd/env/set.go +++ b/cmd/env/set.go @@ -2,10 +2,13 @@ package env import ( "context" + "errors" "fmt" + "slices" "strings" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -50,10 +53,28 @@ func (cmd *SetCmd) Run(_ context.Context, arg string) error { return err } - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() if err != nil { return err } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if meta, metaErr := store.Meta(contextName, name); metaErr == nil { + ctxConfig := devsyConfig.Contexts[contextName] + if meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.Secrets, name) { + return fmt.Errorf("%q is attached as a secret; detach it before converting it to an environment variable", name) + } + } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { + return metaErr + } if err := store.Set(contextName, name, value, secrets.KindEnv); err != nil { return err } diff --git a/cmd/secrets/bind.go b/cmd/secrets/bind.go index 09e812776..81a8f0814 100644 --- a/cmd/secrets/bind.go +++ b/cmd/secrets/bind.go @@ -75,7 +75,12 @@ func (cmd *AttachCmd) Run(ctx context.Context, name string) error { } canonical := ref.String() - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } @@ -127,7 +132,12 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error { return err } canonical := ref.String() - devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") if err != nil { return err } diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index 3f11b8802..a5042be32 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/log" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" "github.com/spf13/cobra" ) @@ -33,7 +34,17 @@ func NewDeleteCmd(flags *flags.GlobalFlags) *cobra.Command { } func (cmd *DeleteCmd) Run(_ context.Context, name string) error { - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() + if err != nil { + return err + } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := devsysecrets.NewStoreForConfig(devsyConfig) if err != nil { return err } @@ -46,10 +57,10 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) } - if err := store.Delete(contextName, name); err != nil { + if err := unbindFromConfig(devsyConfig, contextName, name); err != nil { return err } - if err := unbindFromContext(cmd.GlobalFlags, contextName, name); err != nil { + if err := store.Delete(contextName, name); err != nil { return err } @@ -59,11 +70,7 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { // unbindFromContext removes a deleted secret from its context's attached list so // a stale binding is not left pointing at a now-missing secret. -func unbindFromContext(globalFlags *flags.GlobalFlags, contextName, name string) error { - devsyConfig, err := config.LoadConfig(globalFlags.Context, globalFlags.Provider) - if err != nil { - return err - } +func unbindFromConfig(devsyConfig *config.Config, contextName, name string) error { ctxConfig := devsyConfig.Contexts[contextName] if ctxConfig == nil { return nil diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index c1f4d1bc0..6473ca2b9 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -2,12 +2,15 @@ package secrets import ( "context" + "errors" "fmt" "io" "os" + "slices" "strings" "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -65,10 +68,28 @@ func (cmd *SetCmd) Run(_ context.Context, name string) error { return err } - contextName, store, err := resolveContext(cmd.GlobalFlags) + unlock, err := config.LockConfig() if err != nil { return err } + defer unlock() + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } + contextName := devsyConfig.DefaultContext + store, err := secrets.NewStoreForConfig(devsyConfig) + if err != nil { + return err + } + if meta, metaErr := store.Meta(contextName, name); metaErr == nil { + ctxConfig := devsyConfig.Contexts[contextName] + if !meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.EnvVars, name) { + return fmt.Errorf("%q is attached as an environment variable; detach it before converting it to a secret", name) + } + } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { + return metaErr + } if err := store.Set(contextName, name, value, secrets.KindSecret); err != nil { return err diff --git a/cmd/secrets/set_internal_test.go b/cmd/secrets/set_internal_test.go index 087f4452a..8fd72515c 100644 --- a/cmd/secrets/set_internal_test.go +++ b/cmd/secrets/set_internal_test.go @@ -1,9 +1,15 @@ package secrets import ( + "context" "os" "strings" "testing" + + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/config" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" ) func withStdin(t *testing.T, input string, fn func()) { @@ -25,6 +31,36 @@ func withStdin(t *testing.T, input string, fn func()) { _ = r.Close() } +func TestSetRejectsConvertingAttachedEnvironmentToSecret(t *testing.T) { + config.ResetPathManager() + t.Cleanup(config.ResetPathManager) + t.Setenv(config.EnvHome, t.TempDir()) + t.Setenv("DEVSY_SECRETS_BACKEND", "file") + t.Setenv("DEVSY_SECRETS_PASSPHRASE", "test-passphrase") + require.NoError(t, config.SaveConfig(&config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{config.DefaultContext: { + EnvVars: []string{"TOKEN"}, + }}, + })) + cfg, err := config.LoadConfig("", "") + require.NoError(t, err) + store, err := devsysecrets.NewStoreForConfig(cfg) + require.NoError(t, err) + require.NoError(t, store.Set(config.DefaultContext, "TOKEN", "plain", devsysecrets.KindEnv)) + + err = (&SetCmd{ + GlobalFlags: &flags.GlobalFlags{}, + Value: "secret", + valueSet: true, + }).Run(context.Background(), "TOKEN") + require.Error(t, err) + require.Contains(t, err.Error(), "attached as an environment variable") + meta, err := store.Meta(config.DefaultContext, "TOKEN") + require.NoError(t, err) + require.Equal(t, devsysecrets.KindEnv, meta.Kind) +} + func TestResolveValue_Stdin(t *testing.T) { cmd := &SetCmd{Stdin: true} var got string From eac3d6ef5d3c7977725600e52cbeaf37dec4f628 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:56:05 -0600 Subject: [PATCH 10/20] fix(workspace): preserve repeated explicit env mappings --- cmd/workspace/up/secrets_test.go | 24 ++++++++++++++++++++++ cmd/workspace/up/up_client.go | 34 +++++++++++++++++--------------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/cmd/workspace/up/secrets_test.go b/cmd/workspace/up/secrets_test.go index 36cf34118..75172a464 100644 --- a/cmd/workspace/up/secrets_test.go +++ b/cmd/workspace/up/secrets_test.go @@ -151,6 +151,30 @@ func TestCollectEnvVarRequests_ExplicitOverridesAttachment(t *testing.T) { assert.Equal(t, []envVarRequest{{ref: localRef("LOG_LEVEL"), target: "APP_LOG"}}, got) } +func TestCollectEnvVarRequests_PreservesRepeatedExplicitSource(t *testing.T) { + got, err := collectEnvVarRequests( + []string{"LOG_LEVEL=FIRST", "LOG_LEVEL=SECOND"}, + testEnvConfig(), + ) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("LOG_LEVEL"), target: "FIRST"}, + {ref: localRef("LOG_LEVEL"), target: "SECOND"}, + }, got) +} + +func TestCollectEnvVarRequests_ExplicitRepeatedSourceSuppressesImplicit(t *testing.T) { + got, err := collectEnvVarRequests( + []string{"LOG_LEVEL=FIRST", "LOG_LEVEL=SECOND"}, + testEnvConfig("LOG_LEVEL"), + ) + require.NoError(t, err) + assert.Equal(t, []envVarRequest{ + {ref: localRef("LOG_LEVEL"), target: "FIRST"}, + {ref: localRef("LOG_LEVEL"), target: "SECOND"}, + }, got) +} + func TestCollectEnvVarRequests_DuplicateTargetRejected(t *testing.T) { requests, err := collectEnvVarRequests( []string{"FOO=APP_MODE", "BAR=APP_MODE"}, diff --git a/cmd/workspace/up/up_client.go b/cmd/workspace/up/up_client.go index 87d370fb6..cda32f889 100644 --- a/cmd/workspace/up/up_client.go +++ b/cmd/workspace/up/up_client.go @@ -356,7 +356,17 @@ type envVarRequest struct { } func collectEnvVarRequests(flags []string, devsyConfig *config.Config) ([]envVarRequest, error) { - byRef := map[string]envVarRequest{} + explicit := make([]envVarRequest, 0, len(flags)) + explicitRefs := map[string]struct{}{} + for _, entry := range flags { + req, err := parseEnvVarRequest(entry) + if err != nil { + return nil, err + } + explicit = append(explicit, req) + explicitRefs[req.ref.String()] = struct{}{} + } + implicit := make([]envVarRequest, 0) if current := devsyConfig.Current(); current != nil { for _, name := range current.EnvVars { ref, err := secrets.ParseRef(name) @@ -369,25 +379,17 @@ func collectEnvVarRequests(flags []string, devsyConfig *config.Config) ([]envVar name, ) } - byRef[ref.String()] = envVarRequest{ref: ref, target: ref.Name} - } - } - for _, entry := range flags { - req, err := parseEnvVarRequest(entry) - if err != nil { - return nil, err + if _, overridden := explicitRefs[ref.String()]; overridden { + continue + } + implicit = append(implicit, envVarRequest{ref: ref, target: ref.Name}) } - byRef[req.ref.String()] = req - } - requests := make([]envVarRequest, 0, len(byRef)) - for _, req := range byRef { - requests = append(requests, req) } sort.Slice( - requests, - func(i, j int) bool { return requests[i].ref.String() < requests[j].ref.String() }, + implicit, + func(i, j int) bool { return implicit[i].ref.String() < implicit[j].ref.String() }, ) - return requests, nil + return append(implicit, explicit...), nil } func parseEnvVarRequest(entry string) (envVarRequest, error) { From c8371199378eac34671cff099425fd8ec95d2f2a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:56:10 -0600 Subject: [PATCH 11/20] fix(desktop): scope env attachments to context --- .../main/__tests__/ipc-provider-jobs.test.ts | 20 +++++++++++--- desktop/src/main/ipc.ts | 10 ++++--- .../src/renderer/src/lib/ipc/commands.test.ts | 10 ++++--- desktop/src/renderer/src/lib/ipc/commands.ts | 8 +++--- desktop/src/renderer/src/pages/EnvPage.svelte | 26 ++++++++++++------- .../src/renderer/src/pages/EnvPage.test.ts | 16 ++++++++++-- 6 files changed, 62 insertions(+), 28 deletions(-) diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index 09f5322d2..6b34d0271 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -101,10 +101,22 @@ describe("provider job lifecycle over IPC", () => { it("forwards managed environment attachment intent to the CLI", async () => { const { cli } = setup() - await invoke("env_attach", { name: "LOG_LEVEL" }) - await invoke("env_detach", { name: "LOG_LEVEL" }) - expect(cli.runRaw).toHaveBeenCalledWith(["env", "attach", "LOG_LEVEL"]) - expect(cli.runRaw).toHaveBeenCalledWith(["env", "detach", "LOG_LEVEL"]) + await invoke("env_attach", { name: "LOG_LEVEL", context: "staging" }) + await invoke("env_detach", { name: "LOG_LEVEL", context: "staging" }) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "attach", + "LOG_LEVEL", + ]) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "detach", + "LOG_LEVEL", + ]) }) it("clears the job when init succeeds", async () => { diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 23ff405fd..e6a2918d8 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -1148,10 +1148,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { } }) - ipcMain.handle("env_attach", async (_event, args: { name: string }) => { + ipcMain.handle("env_attach", async (_event, args: { name: string; context: string }) => { trackEvent("env_attach") try { - await cli.runRaw(["env", "attach", args.name]) + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "attach", args.name]) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError @@ -1160,10 +1161,11 @@ export function registerIpcHandlers(deps: IpcDependencies): { } }) - ipcMain.handle("env_detach", async (_event, args: { name: string }) => { + ipcMain.handle("env_detach", async (_event, args: { name: string; context: string }) => { trackEvent("env_detach") try { - await cli.runRaw(["env", "detach", args.name]) + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "detach", args.name]) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index 6c87e3ab9..1e296f25c 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -145,19 +145,21 @@ describe("IPC commands", () => { }) describe("managed environment commands", () => { - it("envAttach sends only the variable name", async () => { + it("envAttach sends the variable name and context", async () => { mockInvoke.mockResolvedValue({ ok: true }) - await envAttach("LOG_LEVEL") + await envAttach("LOG_LEVEL", "staging") expect(mockInvoke).toHaveBeenCalledWith("env_attach", { name: "LOG_LEVEL", + context: "staging", }) }) - it("envDetach sends only the variable name", async () => { + it("envDetach sends the variable name and context", async () => { mockInvoke.mockResolvedValue({ ok: true }) - await envDetach("LOG_LEVEL") + await envDetach("LOG_LEVEL", "staging") expect(mockInvoke).toHaveBeenCalledWith("env_detach", { name: "LOG_LEVEL", + context: "staging", }) }) }) diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 4743a20cd..1bc1f3a0b 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -341,12 +341,12 @@ export async function envDelete(name: string): Promise { unwrapEnvelope(await invoke("env_delete", { name })) } -export async function envAttach(name: string): Promise { - unwrapEnvelope(await invoke("env_attach", { name })) +export async function envAttach(name: string, context: string): Promise { + unwrapEnvelope(await invoke("env_attach", { name, context })) } -export async function envDetach(name: string): Promise { - unwrapEnvelope(await invoke("env_detach", { name })) +export async function envDetach(name: string, context: string): Promise { + unwrapEnvelope(await invoke("env_detach", { name, context })) } // Audit commands diff --git a/desktop/src/renderer/src/pages/EnvPage.svelte b/desktop/src/renderer/src/pages/EnvPage.svelte index 6c1032596..89ce5fe7c 100644 --- a/desktop/src/renderer/src/pages/EnvPage.svelte +++ b/desktop/src/renderer/src/pages/EnvPage.svelte @@ -25,6 +25,7 @@ let confirmDeleteOpen = $state(false) let pendingDelete = $state("") let deleting = $state(false) let updatingAttachment = $state>({}) +let attachmentErrors = $state>({}) let searchTerm = $state("") let filteredEnvVars = $derived.by(() => { @@ -95,18 +96,18 @@ async function confirmDelete() { } async function setAttached(envVar: EnvVar, attached: boolean) { - if (updatingAttachment[envVar.name]) return - updatingAttachment = { ...updatingAttachment, [envVar.name]: true } + const key = `${envVar.context}\x00${envVar.name}` + if (updatingAttachment[key]) return + updatingAttachment = { ...updatingAttachment, [key]: true } + attachmentErrors = { ...attachmentErrors, [key]: "" } try { - if (attached) await envAttach(envVar.name) - else await envDetach(envVar.name) + if (attached) await envAttach(envVar.name, envVar.context) + else await envDetach(envVar.name, envVar.context) await refreshEnv() } catch (err) { - toasts.error( - `Failed to ${attached ? "attach" : "detach"} environment variable: ${extractErrorMessage(err)}`, - ) + attachmentErrors = { ...attachmentErrors, [key]: extractErrorMessage(err) } } finally { - updatingAttachment = { ...updatingAttachment, [envVar.name]: false } + updatingAttachment = { ...updatingAttachment, [key]: false } } } @@ -206,7 +207,7 @@ function toggleReveal(name: string) { {:else}
- {#each filteredEnvVars as envVar (envVar.name)} + {#each filteredEnvVars as envVar (`${envVar.context}\x00${envVar.name}`)}
@@ -241,11 +242,16 @@ function toggleReveal(name: string) {
setAttached(envVar, checked)} />
+ {#if attachmentErrors[`${envVar.context}\x00${envVar.name}`]} +

+ Failed to update attachment: {attachmentErrors[`${envVar.context}\x00${envVar.name}`]} +

+ {/if}
{/each}
diff --git a/desktop/src/renderer/src/pages/EnvPage.test.ts b/desktop/src/renderer/src/pages/EnvPage.test.ts index 7f3537322..60821c548 100644 --- a/desktop/src/renderer/src/pages/EnvPage.test.ts +++ b/desktop/src/renderer/src/pages/EnvPage.test.ts @@ -19,6 +19,7 @@ vi.mock("$lib/stores/env.js", async () => { envVars: writable([ { name: "ATTACHED", value: "one", context: "default", attached: true }, { name: "DETACHED", value: "two", context: "default", attached: false }, + { name: "STAGING_ONLY", value: "three", context: "staging", attached: false }, ]), refreshEnv: mocks.refreshEnv, } @@ -46,7 +47,7 @@ describe("EnvPage managed environment attachments", () => { await fireEvent.click(detached) await waitFor(() => - expect(mocks.envAttach).toHaveBeenCalledWith("DETACHED"), + expect(mocks.envAttach).toHaveBeenCalledWith("DETACHED", "default"), ) expect(mocks.refreshEnv).toHaveBeenCalled() @@ -54,7 +55,18 @@ describe("EnvPage managed environment attachments", () => { screen.getByRole("switch", { name: "Inject ATTACHED into workspaces" }), ) await waitFor(() => - expect(mocks.envDetach).toHaveBeenCalledWith("ATTACHED"), + expect(mocks.envDetach).toHaveBeenCalledWith("ATTACHED", "default"), + ) + }) + + it("uses the context displayed on a stale row", async () => { + render(EnvPage) + + await fireEvent.click( + screen.getByRole("switch", { name: "Inject STAGING_ONLY into workspaces" }), + ) + await waitFor(() => + expect(mocks.envAttach).toHaveBeenCalledWith("STAGING_ONLY", "staging"), ) }) }) From a878454dddde05c26833687d9c478bc8eca35fa2 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 00:57:58 -0600 Subject: [PATCH 12/20] test(desktop): isolate environment attachment renders --- desktop/src/renderer/src/pages/EnvPage.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/desktop/src/renderer/src/pages/EnvPage.test.ts b/desktop/src/renderer/src/pages/EnvPage.test.ts index 60821c548..27745cb7e 100644 --- a/desktop/src/renderer/src/pages/EnvPage.test.ts +++ b/desktop/src/renderer/src/pages/EnvPage.test.ts @@ -1,5 +1,5 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/svelte" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/svelte" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ envAttach: vi.fn().mockResolvedValue(undefined), @@ -31,6 +31,9 @@ describe("EnvPage managed environment attachments", () => { beforeEach(() => { vi.clearAllMocks() }) + afterEach(() => { + cleanup() + }) it("renders attachment state and sends attach/detach intent", async () => { render(EnvPage) From 0f409f3ea5d044af93bb8e7d2f3caaf34aa60c89 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 01:01:31 -0600 Subject: [PATCH 13/20] style: apply Go formatter output --- cmd/env/bind_test.go | 10 ++++++++-- cmd/env/set.go | 5 ++++- cmd/secrets/set.go | 5 ++++- pkg/config/config.go | 3 ++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/env/bind_test.go b/cmd/env/bind_test.go index 5b7fcef2d..8bd9fed23 100644 --- a/cmd/env/bind_test.go +++ b/cmd/env/bind_test.go @@ -88,7 +88,10 @@ func TestSetRejectsConvertingAttachedSecretToEnvironment(t *testing.T) { func TestConcurrentAttachmentsPreserveBothChanges(t *testing.T) { globalFlags := setupEnvCommandTest(t) set := func(name string) { - require.NoError(t, (&SetCmd{GlobalFlags: globalFlags, Value: name}).Run(context.Background(), name)) + require.NoError( + t, + (&SetCmd{GlobalFlags: globalFlags, Value: name}).Run(context.Background(), name), + ) } set("FIRST") set("SECOND") @@ -98,7 +101,10 @@ func TestConcurrentAttachmentsPreserveBothChanges(t *testing.T) { wg.Add(1) go func(name string) { defer wg.Done() - require.NoError(t, (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), name)) + require.NoError( + t, + (&AttachCmd{GlobalFlags: globalFlags}).Run(context.Background(), name), + ) }(name) } wg.Wait() diff --git a/cmd/env/set.go b/cmd/env/set.go index 173e93934..bfc99a592 100644 --- a/cmd/env/set.go +++ b/cmd/env/set.go @@ -70,7 +70,10 @@ func (cmd *SetCmd) Run(_ context.Context, arg string) error { if meta, metaErr := store.Meta(contextName, name); metaErr == nil { ctxConfig := devsyConfig.Contexts[contextName] if meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.Secrets, name) { - return fmt.Errorf("%q is attached as a secret; detach it before converting it to an environment variable", name) + return fmt.Errorf( + "%q is attached as a secret; detach it before converting it to an environment variable", + name, + ) } } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { return metaErr diff --git a/cmd/secrets/set.go b/cmd/secrets/set.go index 6473ca2b9..04c2c248b 100644 --- a/cmd/secrets/set.go +++ b/cmd/secrets/set.go @@ -85,7 +85,10 @@ func (cmd *SetCmd) Run(_ context.Context, name string) error { if meta, metaErr := store.Meta(contextName, name); metaErr == nil { ctxConfig := devsyConfig.Contexts[contextName] if !meta.Sensitive() && ctxConfig != nil && slices.Contains(ctxConfig.EnvVars, name) { - return fmt.Errorf("%q is attached as an environment variable; detach it before converting it to a secret", name) + return fmt.Errorf( + "%q is attached as an environment variable; detach it before converting it to a secret", + name, + ) } } else if !errors.Is(metaErr, secrets.ErrSecretNotFound) { return metaErr diff --git a/pkg/config/config.go b/pkg/config/config.go index f3290ea8a..512c47092 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -329,7 +329,8 @@ func SaveConfig(config *Config) error { config = CloneConfig(config) selectedContext := config.DefaultContext - if selected := config.Contexts[selectedContext]; selected != nil && selected.OriginalProvider != "" { + if selected := config.Contexts[selectedContext]; selected != nil && + selected.OriginalProvider != "" { selected.DefaultProvider = selected.OriginalProvider } if config.OriginalContext != "" { From 2ed87be7d659b9083f896250001461460c259318 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 01:07:20 -0600 Subject: [PATCH 14/20] fix(config): satisfy atomic writer lint --- pkg/config/config.go | 26 ++++++++++++++++++++------ pkg/config/config_test.go | 8 +++++--- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 512c47092..aceb324dc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -370,10 +370,18 @@ func LockConfig() (func(), error) { func writeConfigAtomic(configOrigin string, data []byte) error { dir := filepath.Dir(configOrigin) - tmp, err := os.CreateTemp(dir, filepath.Base(configOrigin)+".tmp-*") + tmpName, err := createTempConfig(dir, filepath.Base(configOrigin), data) if err != nil { return err } + return replaceConfig(tmpName, configOrigin, dir) +} + +func createTempConfig(dir, base string, data []byte) (string, error) { + tmp, err := os.CreateTemp(dir, base+".tmp-*") + if err != nil { + return "", err + } tmpName := tmp.Name() success := false defer func() { @@ -383,21 +391,27 @@ func writeConfigAtomic(configOrigin string, data []byte) error { } }() if err := tmp.Chmod(0o600); err != nil { - return err + return "", err } if _, err := tmp.Write(data); err != nil { - return err + return "", err } if err := tmp.Sync(); err != nil { - return err + return "", err } if err := tmp.Close(); err != nil { - return err + return "", err } + success = true + return tmpName, nil +} + +func replaceConfig(tmpName, configOrigin, dir string) error { + defer func() { _ = os.Remove(tmpName) }() if err := os.Rename(tmpName, configOrigin); err != nil { return err } - success = true + // #nosec G304 -- dir is derived from the managed config path. if dirFile, err := os.Open(dir); err == nil { _ = dirFile.Sync() _ = dirFile.Close() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 6b80d915f..1b8d93fb4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -2,6 +2,8 @@ package config import "testing" +const testEnvName = "LOG_LEVEL" + func TestLoadConfig_StampsCurrentSchemaVersion(t *testing.T) { ResetPathManager() t.Cleanup(ResetPathManager) @@ -73,7 +75,7 @@ func TestContextConfigEnvVarsRoundTrip(t *testing.T) { want := &Config{ DefaultContext: DefaultContext, Contexts: map[string]*ContextConfig{ - DefaultContext: {EnvVars: []string{"LOG_LEVEL"}}, + DefaultContext: {EnvVars: []string{testEnvName}}, }, } if err := SaveConfig(want); err != nil { @@ -107,7 +109,7 @@ func TestSaveConfigRestoresTemporaryContextAndProviderOverrides(t *testing.T) { if err != nil { t.Fatal(err) } - loaded.Current().EnvVars = []string{"LOG_LEVEL"} + loaded.Current().EnvVars = []string{testEnvName} if err := SaveConfig(loaded); err != nil { t.Fatal(err) } @@ -122,7 +124,7 @@ func TestSaveConfigRestoresTemporaryContextAndProviderOverrides(t *testing.T) { if got.Contexts["staging"].DefaultProvider != "kubernetes" { t.Fatalf("staging provider = %q, want kubernetes", got.Contexts["staging"].DefaultProvider) } - if got.Contexts["staging"].EnvVars[0] != "LOG_LEVEL" { + if got.Contexts["staging"].EnvVars[0] != testEnvName { t.Fatalf("staging EnvVars = %#v", got.Contexts["staging"].EnvVars) } } From 35b0561585dcaa9971aec3fc74008a87c75f5353 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 01:17:32 -0600 Subject: [PATCH 15/20] docs: clarify managed environment invariants --- .../content/docs/developing-in-workspaces/secrets.mdx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx index 342b5f259..ad6fdd21f 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/secrets.mdx @@ -371,3 +371,10 @@ automatic attachment for that reference and injects the stored value as `APP_LOG_LEVEL`. Attaching or detaching does not mutate an already-running workspace; apply changes through the normal workspace start or recreate lifecycle. + +An attached environment variable must be detached before converting the same +stored name into a secret. Likewise, a locally attached secret must be +detached before converting it into an environment variable. This keeps the +non-sensitive environment path separate from protected secret delivery. If a +stored value is deleted, Devsy removes its context attachment as part of the +same operation. From cfe87f364a7ee4d1d745e850362850d32b86729c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 01:19:56 -0600 Subject: [PATCH 16/20] fix(config): restore empty temporary providers --- pkg/config/config.go | 10 +++++++++- pkg/config/config_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index aceb324dc..3c8d3575f 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -57,6 +57,10 @@ type ContextConfig struct { // OriginalProvider is the original default provider OriginalProvider string `json:"-"` + + // OriginalProviderSet records that a transient provider override was applied, + // including when the original provider was empty. + OriginalProviderSet bool `json:"-"` } type ContextOption struct { @@ -230,6 +234,7 @@ func CloneConfig(config *Config) *Config { ctx.IDEs = map[string]*IDEConfig{} } ctx.OriginalProvider = config.Contexts[ctxName].OriginalProvider + ctx.OriginalProviderSet = config.Contexts[ctxName].OriginalProviderSet } ret.Origin = config.Origin ret.OriginalContext = config.OriginalContext @@ -305,6 +310,7 @@ func normalizeConfig(config *Config, contextOverride, providerOverride string) { ensureContextMaps(ctx) if providerOverride != "" { ctx.OriginalProvider = ctx.DefaultProvider + ctx.OriginalProviderSet = true ctx.DefaultProvider = providerOverride } } @@ -330,8 +336,10 @@ func SaveConfig(config *Config) error { config = CloneConfig(config) selectedContext := config.DefaultContext if selected := config.Contexts[selectedContext]; selected != nil && - selected.OriginalProvider != "" { + selected.OriginalProviderSet { selected.DefaultProvider = selected.OriginalProvider + selected.OriginalProvider = "" + selected.OriginalProviderSet = false } if config.OriginalContext != "" { config.DefaultContext = config.OriginalContext diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1b8d93fb4..a24d59f6e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -128,3 +128,32 @@ func TestSaveConfigRestoresTemporaryContextAndProviderOverrides(t *testing.T) { t.Fatalf("staging EnvVars = %#v", got.Contexts["staging"].EnvVars) } } + +func TestSaveConfigRestoresEmptyTemporaryProviderOverride(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + t.Setenv(EnvHome, t.TempDir()) + want := &Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {}}, + } + if err := SaveConfig(want); err != nil { + t.Fatal(err) + } + + loaded, err := LoadConfig("", "ssh") + if err != nil { + t.Fatal(err) + } + if err := SaveConfig(loaded); err != nil { + t.Fatal(err) + } + + got, err := LoadConfig("", "") + if err != nil { + t.Fatal(err) + } + if got.Current().DefaultProvider != "" { + t.Fatalf("provider = %q, want empty", got.Current().DefaultProvider) + } +} From 2ed87c7b305c4bdc09ea08f24ed1b1ab1a5f47f3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 11:32:12 -0600 Subject: [PATCH 17/20] fix(config): preserve symlink targets during atomic save --- pkg/config/config.go | 30 +++++++++++++- pkg/config/config_test.go | 84 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 3c8d3575f..1798f5268 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -328,7 +328,7 @@ func ensureContextMaps(ctx *ContextConfig) { } func SaveConfig(config *Config) error { - configOrigin, err := GetConfigPath() + configOrigin, err := getConfigMutationPath() if err != nil { return err } @@ -362,7 +362,7 @@ func SaveConfig(config *Config) error { // processes. Callers must acquire it before loading the config and hold it // until every related persistent operation is complete. func LockConfig() (func(), error) { - configPath, err := GetConfigPath() + configPath, err := getConfigMutationPath() if err != nil { return nil, err } @@ -376,6 +376,32 @@ func LockConfig() (func(), error) { return func() { _ = lock.Unlock() }, nil } +// getConfigMutationPath resolves the configured config file to the path that +// will actually be replaced by an atomic save. A dangling configured symlink +// must fail rather than being replaced by a regular file. +func getConfigMutationPath() (string, error) { + configPath, err := GetConfigPath() + if err != nil { + return "", err + } + + info, err := os.Lstat(configPath) + switch { + case err == nil && info.Mode()&os.ModeSymlink != 0: + resolved, resolveErr := filepath.EvalSymlinks(configPath) + if resolveErr != nil { + return "", fmt.Errorf("resolve config symlink %q: %w", configPath, resolveErr) + } + return resolved, nil + case err == nil: + return configPath, nil + case os.IsNotExist(err): + return configPath, nil + default: + return "", err + } +} + func writeConfigAtomic(configOrigin string, data []byte) error { dir := filepath.Dir(configOrigin) tmpName, err := createTempConfig(dir, filepath.Base(configOrigin), data) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index a24d59f6e..bce77abff 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -1,6 +1,12 @@ package config -import "testing" +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) const testEnvName = "LOG_LEVEL" @@ -157,3 +163,79 @@ func TestSaveConfigRestoresEmptyTemporaryProviderOverride(t *testing.T) { t.Fatalf("provider = %q, want empty", got.Current().DefaultProvider) } } + +func TestSaveConfigPreservesAbsoluteConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + targetDir := filepath.Join(root, "target") + linkDir := filepath.Join(root, "link") + require.NoError(t, os.MkdirAll(targetDir, 0o700)) + require.NoError(t, os.MkdirAll(linkDir, 0o700)) + targetPath := filepath.Join(targetDir, ConfigFile) + linkPath := filepath.Join(linkDir, ConfigFile) + require.NoError(t, os.WriteFile(targetPath, []byte("{}\n"), 0o600)) + require.NoError(t, os.Symlink(targetPath, linkPath)) + t.Setenv(EnvConfig, linkPath) + + require.NoError(t, SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {EnvVars: []string{testEnvName}}}, + })) + + info, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + got, err := LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{testEnvName}, got.Current().EnvVars) +} + +func TestSaveConfigPreservesRelativeConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + configDir := filepath.Join(root, "config") + sharedDir := filepath.Join(root, "shared") + require.NoError(t, os.MkdirAll(configDir, 0o700)) + require.NoError(t, os.MkdirAll(sharedDir, 0o700)) + targetPath := filepath.Join(sharedDir, ConfigFile) + linkPath := filepath.Join(configDir, ConfigFile) + require.NoError(t, os.WriteFile(targetPath, []byte("{}\n"), 0o600)) + const linkTarget = "../shared/config.yaml" + require.NoError(t, os.Symlink(linkTarget, linkPath)) + t.Setenv(EnvConfig, linkPath) + + require.NoError(t, SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {EnvVars: []string{testEnvName}}}, + })) + + info, err := os.Lstat(linkPath) + require.NoError(t, err) + require.NotZero(t, info.Mode()&os.ModeSymlink) + gotTarget, err := os.Readlink(linkPath) + require.NoError(t, err) + require.Equal(t, linkTarget, gotTarget) + got, err := LoadConfig("", "") + require.NoError(t, err) + require.Equal(t, []string{testEnvName}, got.Current().EnvVars) +} + +func TestSaveConfigDoesNotReplaceDanglingConfigSymlink(t *testing.T) { + ResetPathManager() + t.Cleanup(ResetPathManager) + root := t.TempDir() + linkPath := filepath.Join(root, ConfigFile) + require.NoError(t, os.Symlink("missing.yaml", linkPath)) + t.Setenv(EnvConfig, linkPath) + + err := SaveConfig(&Config{ + DefaultContext: DefaultContext, + Contexts: map[string]*ContextConfig{DefaultContext: {}}, + }) + require.Error(t, err) + info, statErr := os.Lstat(linkPath) + require.NoError(t, statErr) + require.NotZero(t, info.Mode()&os.ModeSymlink) +} From 8448bc3eff618ed76c98a4c6414a8a876e0137b8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 11:32:13 -0600 Subject: [PATCH 18/20] fix(env): compensate failed value deletion --- cmd/env/delete.go | 63 ++++++++++++++++++-- cmd/env/delete_test.go | 127 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 5 deletions(-) create mode 100644 cmd/env/delete_test.go diff --git a/cmd/env/delete.go b/cmd/env/delete.go index 38b895067..9e8911435 100644 --- a/cmd/env/delete.go +++ b/cmd/env/delete.go @@ -2,6 +2,7 @@ package env import ( "context" + "errors" "fmt" "slices" @@ -55,17 +56,69 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { if meta.Sensitive() { return fmt.Errorf("%q is a secret; use \"devsy secret delete\"", name) } + if err := deleteEnvironmentValue(deleteEnvRequest{ + config: devsyConfig, + store: store, + context: contextName, + name: name, + save: config.SaveConfig, + }); err != nil { + return err + } + log.Infof("env var %q deleted from context %q", name, contextName) + return nil +} + +type deleteEnvRequest struct { + config *config.Config + store secrets.Store + context string + name string + save func(*config.Config) error +} + +type removedEnvBinding struct { + attached bool + index int +} + +func deleteEnvironmentValue(request deleteEnvRequest) error { + devsyConfig := request.config + store := request.store + contextName := request.context + name := request.name + saveConfig := request.save + var binding removedEnvBinding if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { if idx := slices.Index(ctxConfig.EnvVars, name); idx >= 0 { + binding = removedEnvBinding{attached: true, index: idx} ctxConfig.EnvVars = slices.Delete(ctxConfig.EnvVars, idx, idx+1) - if err := config.SaveConfig(devsyConfig); err != nil { + if err := saveConfig(devsyConfig); err != nil { return err } } } - if err := store.Delete(contextName, name); err != nil { - return err + + deleteErr := store.Delete(contextName, name) + if deleteErr == nil || !binding.attached { + return deleteErr } - log.Infof("env var %q deleted from context %q", name, contextName) - return nil + + if _, err := store.Get(contextName, name); err != nil { + return deleteErr + } + + ctxConfig := devsyConfig.Contexts[contextName] + ctxConfig.EnvVars = append(ctxConfig.EnvVars, "") + copy(ctxConfig.EnvVars[binding.index+1:], ctxConfig.EnvVars[binding.index:]) + ctxConfig.EnvVars[binding.index] = name + if rollbackErr := saveConfig(devsyConfig); rollbackErr != nil { + rollbackMessage := "restore environment variable attachment after failed delete: %w" + return errors.Join( + deleteErr, + fmt.Errorf(rollbackMessage, rollbackErr), + ) + } + + return deleteErr } diff --git a/cmd/env/delete_test.go b/cmd/env/delete_test.go new file mode 100644 index 000000000..7109c928b --- /dev/null +++ b/cmd/env/delete_test.go @@ -0,0 +1,127 @@ +package env + +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +type deleteTestStore struct { + value string + deleteErr error + getErr error +} + +const ( + deleteEnvName = "FOO" + deleteTestValue = "delete-value" +) + +func (s *deleteTestStore) Set(string, string, string, secrets.Kind) error { return nil } +func (s *deleteTestStore) Get(string, string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + return s.value, nil +} + +func (s *deleteTestStore) Meta(string, string) (secrets.SecretMeta, error) { + return secrets.SecretMeta{}, nil +} + +func (s *deleteTestStore) List(string) ([]secrets.SecretMeta, error) { return nil, nil } +func (s *deleteTestStore) Delete(string, string) error { return s.deleteErr } + +func TestDeleteEnvironmentValueRestoresAttachedBindingWhenDeleteFails(t *testing.T) { + cfg := deleteTestConfig([]string{"DELETE_FIRST", deleteEnvName, "DELETE_LAST"}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Equal(t, []string{"DELETE_FIRST", deleteEnvName, "DELETE_LAST"}, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueLeavesBindingRemovedWhenValueUnavailable(t *testing.T) { + cfg := deleteTestConfig([]string{deleteEnvName}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{deleteErr: deleteErr, getErr: secrets.ErrSecretNotFound} + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Empty(t, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueJoinsRollbackFailure(t *testing.T) { + cfg := deleteTestConfig([]string{deleteEnvName}) + deleteErr := errors.New("delete failed") + rollbackErr := errors.New("rollback failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + saves := 0 + + err := deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + saves++ + if saves == 2 { + return rollbackErr + } + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.ErrorIs(t, err, rollbackErr) + require.Equal(t, []string{deleteEnvName}, cfg.Current().EnvVars) +} + +func TestDeleteEnvironmentValueDoesNotSaveForUnattachedValue(t *testing.T) { + cfg := deleteTestConfig(nil) + saves := 0 + store := &deleteTestStore{value: deleteTestValue} + + require.NoError(t, deleteEnvironmentValue(deleteEnvRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteEnvName, + save: func(*config.Config) error { + saves++ + return nil + }, + })) + require.Zero(t, saves) +} + +func deleteTestConfig(envVars []string) *config.Config { + return &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{ + config.DefaultContext: {EnvVars: envVars}, + }, + } +} From f68b2254a3fe383086782fb8c72ce9361ba9168a Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 11:32:13 -0600 Subject: [PATCH 19/20] fix(secrets): compensate failed secret deletion --- cmd/secrets/delete.go | 71 +++++++++++++++----- cmd/secrets/delete_test.go | 131 +++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 cmd/secrets/delete_test.go diff --git a/cmd/secrets/delete.go b/cmd/secrets/delete.go index a5042be32..f60f37c66 100644 --- a/cmd/secrets/delete.go +++ b/cmd/secrets/delete.go @@ -2,6 +2,7 @@ package secrets import ( "context" + "errors" "fmt" "slices" @@ -57,10 +58,13 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { return fmt.Errorf("%q is an environment variable; use \"devsy env delete\"", name) } - if err := unbindFromConfig(devsyConfig, contextName, name); err != nil { - return err - } - if err := store.Delete(contextName, name); err != nil { + if err := deleteSecretValue(deleteSecretRequest{ + config: devsyConfig, + store: store, + context: contextName, + name: name, + save: config.SaveConfig, + }); err != nil { return err } @@ -68,18 +72,55 @@ func (cmd *DeleteCmd) Run(_ context.Context, name string) error { return nil } -// unbindFromContext removes a deleted secret from its context's attached list so -// a stale binding is not left pointing at a now-missing secret. -func unbindFromConfig(devsyConfig *config.Config, contextName, name string) error { - ctxConfig := devsyConfig.Contexts[contextName] - if ctxConfig == nil { - return nil +type deleteSecretRequest struct { + config *config.Config + store devsysecrets.Store + context string + name string + save func(*config.Config) error +} + +type removedSecretBinding struct { + attached bool + index int +} + +func deleteSecretValue(request deleteSecretRequest) error { + devsyConfig := request.config + store := request.store + contextName := request.context + name := request.name + saveConfig := request.save + var binding removedSecretBinding + if ctxConfig := devsyConfig.Contexts[contextName]; ctxConfig != nil { + if idx := slices.Index(ctxConfig.Secrets, name); idx >= 0 { + binding = removedSecretBinding{attached: true, index: idx} + ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) + if err := saveConfig(devsyConfig); err != nil { + return err + } + } } - idx := slices.Index(ctxConfig.Secrets, name) - if idx < 0 { - return nil + + deleteErr := store.Delete(contextName, name) + if deleteErr == nil || !binding.attached { + return deleteErr + } + + if _, err := store.Get(contextName, name); err != nil { + return deleteErr + } + + ctxConfig := devsyConfig.Contexts[contextName] + ctxConfig.Secrets = append(ctxConfig.Secrets, "") + copy(ctxConfig.Secrets[binding.index+1:], ctxConfig.Secrets[binding.index:]) + ctxConfig.Secrets[binding.index] = name + if rollbackErr := saveConfig(devsyConfig); rollbackErr != nil { + return errors.Join( + deleteErr, + fmt.Errorf("restore secret attachment after failed delete: %w", rollbackErr), + ) } - ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1) - return config.SaveConfig(devsyConfig) + return deleteErr } diff --git a/cmd/secrets/delete_test.go b/cmd/secrets/delete_test.go new file mode 100644 index 000000000..9361d2caa --- /dev/null +++ b/cmd/secrets/delete_test.go @@ -0,0 +1,131 @@ +package secrets + +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/config" + devsysecrets "github.com/devsy-org/devsy/pkg/secrets" + "github.com/stretchr/testify/require" +) + +type deleteTestStore struct { + value string + deleteErr error + getErr error +} + +const ( + deleteSecretName = "DELETE_TOKEN" + deleteTestValue = "delete-value" +) + +func (s *deleteTestStore) Set(string, string, string, devsysecrets.Kind) error { return nil } +func (s *deleteTestStore) Get(string, string) (string, error) { + if s.getErr != nil { + return "", s.getErr + } + return s.value, nil +} + +func (s *deleteTestStore) Meta(string, string) (devsysecrets.SecretMeta, error) { + return devsysecrets.SecretMeta{}, nil +} + +func (s *deleteTestStore) List(string) ([]devsysecrets.SecretMeta, error) { return nil, nil } +func (s *deleteTestStore) Delete(string, string) error { return s.deleteErr } + +func TestDeleteSecretValueRestoresAttachedBindingWhenDeleteFails(t *testing.T) { + cfg := deleteTestConfig([]string{"DELETE_FIRST", deleteSecretName, "DELETE_LAST"}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Equal( + t, + []string{"DELETE_FIRST", deleteSecretName, "DELETE_LAST"}, + cfg.Current().Secrets, + ) +} + +func TestDeleteSecretValueLeavesBindingRemovedWhenValueUnavailable(t *testing.T) { + cfg := deleteTestConfig([]string{deleteSecretName}) + deleteErr := errors.New("delete failed") + store := &deleteTestStore{deleteErr: deleteErr, getErr: devsysecrets.ErrSecretNotFound} + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.Empty(t, cfg.Current().Secrets) +} + +func TestDeleteSecretValueJoinsRollbackFailure(t *testing.T) { + cfg := deleteTestConfig([]string{deleteSecretName}) + deleteErr := errors.New("delete failed") + rollbackErr := errors.New("rollback failed") + store := &deleteTestStore{value: deleteTestValue, deleteErr: deleteErr} + saves := 0 + + err := deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + saves++ + if saves == 2 { + return rollbackErr + } + return nil + }, + }) + + require.ErrorIs(t, err, deleteErr) + require.ErrorIs(t, err, rollbackErr) + require.Equal(t, []string{deleteSecretName}, cfg.Current().Secrets) +} + +func TestDeleteSecretValueDoesNotSaveForUnattachedValue(t *testing.T) { + cfg := deleteTestConfig(nil) + saves := 0 + store := &deleteTestStore{value: deleteTestValue} + + require.NoError(t, deleteSecretValue(deleteSecretRequest{ + config: cfg, + store: store, + context: config.DefaultContext, + name: deleteSecretName, + save: func(*config.Config) error { + saves++ + return nil + }, + })) + require.Zero(t, saves) +} + +func deleteTestConfig(secretsList []string) *config.Config { + return &config.Config{ + DefaultContext: config.DefaultContext, + Contexts: map[string]*config.ContextConfig{ + config.DefaultContext: {Secrets: secretsList}, + }, + } +} From 67ca47986dfc97b0168c623379f602b940c87539 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 24 Sep 2026 16:14:33 -0600 Subject: [PATCH 20/20] fix(desktop): scope env deletion to displayed context --- .../main/__tests__/ipc-provider-jobs.test.ts | 26 +++++++++++++++++++ desktop/src/main/ipc.ts | 26 +++++++++++-------- .../src/renderer/src/lib/ipc/commands.test.ts | 10 +++++++ desktop/src/renderer/src/lib/ipc/commands.ts | 6 +++-- desktop/src/renderer/src/pages/EnvPage.svelte | 19 ++++++++------ .../src/renderer/src/pages/EnvPage.test.ts | 18 ++++++++++++- 6 files changed, 83 insertions(+), 22 deletions(-) diff --git a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts index 6b34d0271..d1b49ef84 100644 --- a/desktop/src/main/__tests__/ipc-provider-jobs.test.ts +++ b/desktop/src/main/__tests__/ipc-provider-jobs.test.ts @@ -119,6 +119,32 @@ describe("provider job lifecycle over IPC", () => { ]) }) + it("deletes managed environment values in the displayed context", async () => { + const { cli } = setup() + await invoke("env_delete", { name: "LOG_LEVEL", context: "staging" }) + expect(cli.runRaw).toHaveBeenCalledWith([ + "--context", + "staging", + "env", + "delete", + "LOG_LEVEL", + ]) + }) + + it("rejects managed environment deletion without a context", async () => { + const { cli } = setup() + const result = (await invoke("env_delete", { + name: "LOG_LEVEL", + context: "", + })) as { ok: boolean; message: string; cliError?: unknown } + expect(result).toEqual({ + ok: false, + message: "context is required", + cliError: undefined, + }) + expect(cli.runRaw).not.toHaveBeenCalled() + }) + it("clears the job when init succeeds", async () => { const { providerJobs } = setup(() => ({ lines: [statusLine("running_init"), statusLine("ready")], diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index e6a2918d8..d88e43663 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -1136,17 +1136,21 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, ) - ipcMain.handle("env_delete", async (_event, args: { name: string }) => { - trackEvent("env_delete") - try { - await cli.runRaw(["env", "delete", args.name]) - return { ok: true } as const - } catch (err) { - const cliError = (err as { cliError?: CLIError }).cliError - const message = err instanceof Error ? err.message : String(err) - return { ok: false, message, cliError } as const - } - }) + ipcMain.handle( + "env_delete", + async (_event, args: { name: string; context: string }) => { + trackEvent("env_delete") + try { + if (!args.context) throw new Error("context is required") + await cli.runRaw(["--context", args.context, "env", "delete", args.name]) + return { ok: true } as const + } catch (err) { + const cliError = (err as { cliError?: CLIError }).cliError + const message = err instanceof Error ? err.message : String(err) + return { ok: false, message, cliError } as const + } + }, + ) ipcMain.handle("env_attach", async (_event, args: { name: string; context: string }) => { trackEvent("env_attach") diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index 1e296f25c..3fd4e0c64 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -7,6 +7,7 @@ import { auditRecent, contextUse, devsyVersion, + envDelete, envAttach, envDetach, machineCreate, @@ -162,6 +163,15 @@ describe("IPC commands", () => { context: "staging", }) }) + + it("envDelete sends the variable name and context", async () => { + mockInvoke.mockResolvedValue({ ok: true }) + await envDelete("LOG_LEVEL", "staging") + expect(mockInvoke).toHaveBeenCalledWith("env_delete", { + name: "LOG_LEVEL", + context: "staging", + }) + }) }) describe("provider commands", () => { diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 1bc1f3a0b..8b6aa0f52 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -337,8 +337,10 @@ export async function envSet(name: string, value: string): Promise { unwrapEnvelope(await invoke("env_set", { name, value })) } -export async function envDelete(name: string): Promise { - unwrapEnvelope(await invoke("env_delete", { name })) +export async function envDelete(name: string, context: string): Promise { + unwrapEnvelope( + await invoke("env_delete", { name, context }), + ) } export async function envAttach(name: string, context: string): Promise { diff --git a/desktop/src/renderer/src/pages/EnvPage.svelte b/desktop/src/renderer/src/pages/EnvPage.svelte index 89ce5fe7c..186aba061 100644 --- a/desktop/src/renderer/src/pages/EnvPage.svelte +++ b/desktop/src/renderer/src/pages/EnvPage.svelte @@ -22,7 +22,8 @@ let saving = $state(false) let revealed = $state>({}) let confirmDeleteOpen = $state(false) -let pendingDelete = $state("") +type PendingEnvDelete = { name: string; context: string } +let pendingDelete = $state(null) let deleting = $state(false) let updatingAttachment = $state>({}) let attachmentErrors = $state>({}) @@ -71,17 +72,18 @@ async function handleCreate() { await refreshEnv().catch(() => {}) } -function requestDelete(e: Event, name: string) { +function requestDelete(e: Event, envVar: EnvVar) { e.stopPropagation() - pendingDelete = name + pendingDelete = { name: envVar.name, context: envVar.context } confirmDeleteOpen = true } async function confirmDelete() { - const name = pendingDelete + const target = pendingDelete + if (!target) return deleting = true try { - await envDelete(name) + await envDelete(target.name, target.context) } catch (err) { toasts.error( `Failed to delete environment variable: ${extractErrorMessage(err)}`, @@ -90,7 +92,8 @@ async function confirmDelete() { return } confirmDeleteOpen = false - toasts.success(`Environment variable "${name}" deleted`) + pendingDelete = null + toasts.success(`Environment variable "${target.name}" deleted`) deleting = false await refreshEnv().catch(() => {}) } @@ -227,7 +230,7 @@ function toggleReveal(name: string) { {/if} - @@ -262,7 +265,7 @@ function toggleReveal(name: string) { ({ envAttach: vi.fn().mockResolvedValue(undefined), envDetach: vi.fn().mockResolvedValue(undefined), + envDelete: vi.fn().mockResolvedValue(undefined), refreshEnv: vi.fn().mockResolvedValue(undefined), })) vi.mock("$lib/ipc/commands.js", () => ({ envAttach: mocks.envAttach, - envDelete: vi.fn(), + envDelete: mocks.envDelete, envDetach: mocks.envDetach, envSet: vi.fn(), })) @@ -72,4 +73,19 @@ describe("EnvPage managed environment attachments", () => { expect(mocks.envAttach).toHaveBeenCalledWith("STAGING_ONLY", "staging"), ) }) + + it("deletes using the context displayed on a stale row", async () => { + render(EnvPage) + + await fireEvent.click( + screen.getByRole("button", { + name: "Delete environment variable STAGING_ONLY", + }), + ) + await fireEvent.click(screen.getByRole("button", { name: /^Delete$/ })) + + await waitFor(() => + expect(mocks.envDelete).toHaveBeenCalledWith("STAGING_ONLY", "staging"), + ) + }) })