From 452e1b2c300ca70bafa7cea489b86a5625f75ade Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 17:09:32 +0900 Subject: [PATCH 1/8] feat(container): show connection and current checkout information Share container info between the public and internal agent command trees, using agent initialization without requiring host workspace configuration. Read the selected setup result for the configured remote user, and use the existing workspace SSH alias. Print only the six requested fields, with nullable checkout metadata and the existing plain/JSON format selection. Resolve Git information from cwd, including linked worktrees, detached HEAD and unborn branches. Validate the nearest Git metadata with Git itself so unreadable nested repositories cannot silently select a parent checkout. Classify internal query failures so final CLI rendering retains their causes. Validated focused tests across five packages, command lint, macOS and Linux amd64 builds, independent code review, and built-binary fixtures through both entry points. Live container setup/PATH, host SSH aliases, and IDE connections remain unverified. Co-authored-by: Codex --- cmd/container_info_test.go | 162 +++++++++++++++ cmd/internal/agentcontainer/container.go | 1 + cmd/internal/agentcontainer/info.go | 238 +++++++++++++++++++++++ cmd/internal/agentcontainer/info_test.go | 151 ++++++++++++++ cmd/internal/container.go | 17 ++ cmd/root.go | 1 + 6 files changed, 570 insertions(+) create mode 100644 cmd/container_info_test.go create mode 100644 cmd/internal/agentcontainer/info.go create mode 100644 cmd/internal/agentcontainer/info_test.go create mode 100644 cmd/internal/container.go diff --git a/cmd/container_info_test.go b/cmd/container_info_test.go new file mode 100644 index 0000000000..97fa18fdec --- /dev/null +++ b/cmd/container_info_test.go @@ -0,0 +1,162 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "io" + "os" + "path/filepath" + "runtime" + "testing" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/pkg/config" + "github.com/stretchr/testify/require" +) + +const ( + infoContainerCommand = "container" + infoCommand = "info" + infoWorkspaceID = "test-workspace" + infoIncompleteResult = "incomplete or failed" + infoSetupFixture = `{ + "MergedConfig":{"remoteUser":"configured-user","containerUser":"other-user","remoteEnv":{"TOKEN":"do-not-output"}}, + "ContainerDetails":{"Config":{"User":"image-user"}} + }` +) + +func containerInfoFixture(t *testing.T, result string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("container commands require sh") + } + dir := t.TempDir() + fixture := filepath.Join(dir, "setup-result.json") + require.NoError(t, os.WriteFile(fixture, []byte(result), 0o600)) + script := "#!/bin/sh\nexec /bin/cat " + shellescape.Quote(fixture) + "\n" + file, err := os.CreateTemp(dir, "sh-*") + require.NoError(t, err) + _, err = file.WriteString(script) + require.NoError(t, err) + require.NoError(t, file.Chmod(0o700)) + require.NoError(t, file.Close()) + require.NoError(t, os.Symlink(file.Name(), filepath.Join(dir, "sh"))) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(config.EnvWorkspaceID, infoWorkspaceID) + t.Setenv("DEVSY_UI", "") + t.Setenv("LC_ALL", "C") + t.Setenv("USER", "wrong-os-user") + t.Setenv("LOGNAME", "wrong-os-user") + return fixture +} + +func executeContainerInfo(t *testing.T, path []string, format string) (string, error) { + t.Helper() + root, _ := BuildRoot() + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + args := append([]string{}, path...) + args = append( + args, + "--home", + t.TempDir(), + "--context", + "not-configured", + "--provider", + "not-configured", + "--result-format", + format, + ) + root.SetArgs(args) + err := root.Execute() + return out.String(), err +} + +func TestContainerInfoEntrypoints(t *testing.T) { + containerInfoFixture(t, infoSetupFixture) + t.Chdir(t.TempDir()) + for _, path := range containerInfoEntrypoints() { + t.Run(path[0], func(t *testing.T) { + raw, err := executeContainerInfo(t, path, "json") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &got)) + require.Equal(t, map[string]any{ + "sshCommand": "ssh configured-user@test-workspace.devsy", + "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", + "checkoutRoot": nil, "branch": nil, "commit": nil, + }, got) + text, err := executeContainerInfo(t, path, "plain") + require.NoError(t, err) + require.Equal( + t, + "SSH command: ssh configured-user@test-workspace.devsy\n"+ + "SSH host: test-workspace.devsy\nRemote user: configured-user\n"+ + "Checkout root: (none)\nBranch: (none)\nCommit: (none)\n", + text, + ) + }) + } +} + +func TestContainerInfoSetupFailures(t *testing.T) { + for _, tt := range []struct{ name, result, workspaceID, want string }{ + {"missing workspace", infoSetupFixture, "", config.EnvWorkspaceID}, + {"invalid JSON", "{", infoWorkspaceID, "parse container setup result"}, + {"null result", "null", infoWorkspaceID, infoIncompleteResult}, + {"missing config", `{"ContainerDetails":{}}`, infoWorkspaceID, infoIncompleteResult}, + {"missing container", `{"MergedConfig":{}}`, infoWorkspaceID, infoIncompleteResult}, + { + "failed setup", `{"MergedConfig":{},"ContainerDetails":{},"Error":"private-error"}`, + infoWorkspaceID, infoIncompleteResult, + }, + } { + t.Run(tt.name, func(t *testing.T) { + containerInfoFixture(t, tt.result) + t.Setenv(config.EnvWorkspaceID, tt.workspaceID) + for _, path := range containerInfoEntrypoints() { + raw, err := executeContainerInfo(t, path, "json") + require.ErrorContains(t, err, tt.want) + require.Empty(t, raw) + } + }) + } +} + +func TestContainerInfoReadFailure(t *testing.T) { + fixture := containerInfoFixture(t, infoSetupFixture) + require.NoError(t, os.Remove(fixture)) + raw, err := executeContainerInfo(t, []string{infoContainerCommand, infoCommand}, "json") + require.ErrorContains(t, err, "read selected container setup result") + require.Empty(t, raw) +} + +func containerInfoEntrypoints() [][]string { + return [][]string{ + {infoContainerCommand, infoCommand}, + {"internal", "agent", infoContainerCommand, infoCommand}, + } +} + +func TestContainerInfoFailureRendering(t *testing.T) { + fixture := containerInfoFixture(t, infoSetupFixture) + require.NoError(t, os.Remove(fixture)) + _, err := executeContainerInfo(t, containerInfoEntrypoints()[0], "json") + require.Error(t, err) + for _, machine := range []bool{false, true} { + reader, writer, pipeErr := os.Pipe() + require.NoError(t, pipeErr) + original := os.Stderr + os.Stderr = writer + code := exitCodeForError(err, machine) + os.Stderr = original + require.NoError(t, writer.Close()) + raw, readErr := io.ReadAll(reader) + require.NoError(t, readErr) + require.NoError(t, reader.Close()) + require.Equal(t, 1, code) + require.Contains(t, string(raw), "read selected container setup result") + require.Contains(t, string(raw), "No such file or directory") + } +} diff --git a/cmd/internal/agentcontainer/container.go b/cmd/internal/agentcontainer/container.go index 57a130a973..7f8dd68c38 100644 --- a/cmd/internal/agentcontainer/container.go +++ b/cmd/internal/agentcontainer/container.go @@ -29,5 +29,6 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command { containerCmd.AddCommand(NewCredentialsServerCmd(flags)) containerCmd.AddCommand(NewSetupDevsyPlatformAccessCmd(flags)) containerCmd.AddCommand(NewSSHServerCmd(flags)) + containerCmd.AddCommand(NewInfoCmd(flags)) return containerCmd } diff --git a/cmd/internal/agentcontainer/info.go b/cmd/internal/agentcontainer/info.go new file mode 100644 index 0000000000..983fa223ba --- /dev/null +++ b/cmd/internal/agentcontainer/info.go @@ -0,0 +1,238 @@ +package agentcontainer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/pkg/clierr" + "github.com/devsy-org/devsy/pkg/config" + devconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/output" + "github.com/spf13/cobra" +) + +type containerInfo struct { + SSHCommand string `json:"sshCommand"` + SSHHost string `json:"sshHost"` + RemoteUser string `json:"remoteUser"` + CheckoutRoot *string `json:"checkoutRoot"` + Branch *string `json:"branch"` + Commit *string `json:"commit"` +} + +func NewInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Show this container's SSH connection and current Git checkout", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + mode, err := output.ResolveMode(globalFlags.ResultFormat) + if err != nil { + return err + } + info, err := readContainerInfo(cmd.Context()) + if err != nil { + classified := clierr.Classify(err) + if classified.Code == clierr.CodeUnknown { + classified.Code = "container_info_failed" + } + return classified + } + return writeContainerInfo(cmd.OutOrStdout(), mode, info) + }, + } +} + +func readContainerInfo(ctx context.Context) (*containerInfo, error) { + workspaceID := os.Getenv(config.EnvWorkspaceID) + if workspaceID == "" { + return nil, fmt.Errorf("%s is missing; run inside a Devsy container", config.EnvWorkspaceID) + } + result, err := readInfoSetupResult(ctx) + if err != nil { + return nil, err + } + info := &containerInfo{ + SSHHost: workspaceID + config.SSHHostSuffix, + RemoteUser: devconfig.GetRemoteUser(result), + } + info.SSHCommand = "ssh " + shellescape.Quote(info.RemoteUser+"@"+info.SSHHost) + if err := info.readCheckout(ctx); err != nil { + return nil, err + } + return info, nil +} + +func readInfoSetupResult(ctx context.Context) (*devconfig.Result, error) { + command := exec.CommandContext(ctx, "sh") + command.Args = append(command.Args, "-c", config.ReadDevContainerResultCommand()) + raw, err := command.Output() + if err != nil { + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + return nil, fmt.Errorf( + "read selected container setup result: %w: %s", + err, + strings.TrimSpace(string(exit.Stderr)), + ) + } + return nil, fmt.Errorf("read selected container setup result: %w", err) + } + result := &devconfig.Result{} + if err := json.Unmarshal(raw, result); err != nil { + return nil, fmt.Errorf("parse container setup result: %w", err) + } + if result.MergedConfig == nil || result.ContainerDetails == nil || result.Error != "" { + return nil, fmt.Errorf("container setup result is incomplete or failed") + } + return result, nil +} + +func writeContainerInfo(w io.Writer, mode string, info *containerInfo) error { + if mode == output.ModeJSON { + return json.NewEncoder(w).Encode(info) + } + _, err := fmt.Fprintf( + w, + "SSH command: %s\nSSH host: %s\nRemote user: %s\nCheckout root: %s\nBranch: %s\nCommit: %s\n", + info.SSHCommand, + info.SSHHost, + info.RemoteUser, + infoValue(info.CheckoutRoot), + infoValue(info.Branch), + infoValue(info.Commit), + ) + return err +} + +func infoValue(value *string) string { + if value == nil { + return "(none)" + } + return *value +} + +func (info *containerInfo) readCheckout(ctx context.Context) error { + if err := validateCheckoutMetadata(ctx); err != nil { + return err + } + inside, err := infoGit(ctx, "rev-parse", "--is-inside-work-tree") + if err != nil { + if exit, ok := errors.AsType[*exec.ExitError](err); ok && exit.ExitCode() == 128 && + strings.HasPrefix(string(exit.Stderr), "fatal: not a git repository (or any ") { + return nil + } + return fmt.Errorf("resolve Git checkout root: %w", err) + } + if inside == "false" { + return nil + } + root, err := infoGit(ctx, "rev-parse", "--show-toplevel") + if err != nil { + return fmt.Errorf("resolve Git checkout root: %w", err) + } + info.CheckoutRoot = &root + return info.readGitHead(ctx) +} + +func (info *containerInfo) readGitHead(ctx context.Context) error { + ref, err := infoGit(ctx, "symbolic-ref", "--quiet", "HEAD") + if err != nil && !infoGitExit(err, 1) { + return fmt.Errorf("resolve Git branch: %w", err) + } + if err == nil { + branch := strings.TrimPrefix(ref, "refs/heads/") + info.Branch = &branch + } + commit, err := infoGit(ctx, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + if info.Branch != nil { + _, refErr := infoGit(ctx, "show-ref", "--verify", "--quiet", ref) + if infoGitExit(refErr, 1) { + return nil + } + } + return fmt.Errorf("resolve Git commit: %w", err) + } + info.Commit = &commit + return nil +} + +func infoGit(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "git") + cmd.Args = append(cmd.Args, args...) + cmd.Env = infoGitEnv() + raw, err := cmd.Output() + value := strings.TrimSuffix(string(raw), "\n") + if err != nil { + if exit, ok := errors.AsType[*exec.ExitError](err); ok { + return value, fmt.Errorf( + "git %s: %w: %s", + strings.Join(args, " "), + err, + strings.TrimSpace(string(exit.Stderr)), + ) + } + return value, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + return value, nil +} + +func infoGitExit(err error, code int) bool { + exit, ok := errors.AsType[*exec.ExitError](err) + return ok && exit.ExitCode() == code +} + +func infoGitEnv() []string { + env := make([]string, 0, len(os.Environ())+1) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + switch key { + case "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_PREFIX", "LC_ALL": + continue + } + env = append(env, entry) + } + return append(env, "LC_ALL=C") +} + +func validateCheckoutMetadata(ctx context.Context) error { + metadata, err := findCheckoutMetadata() + if err != nil || metadata == "" { + return err + } + if _, err := infoGit(ctx, "rev-parse", "--resolve-git-dir", metadata); err != nil { + return fmt.Errorf("resolve Git checkout metadata: %w", err) + } + return nil +} + +func findCheckoutMetadata() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve Git checkout directory: %w", err) + } + for { + metadata := filepath.Join(dir, ".git") + _, err := os.Lstat(metadata) + if err == nil { + return metadata, nil + } + if !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("inspect Git checkout metadata: %w", err) + } + parent := filepath.Dir(dir) + if parent == dir { + return "", nil + } + dir = parent + } +} diff --git a/cmd/internal/agentcontainer/info_test.go b/cmd/internal/agentcontainer/info_test.go new file mode 100644 index 0000000000..290974db8b --- /dev/null +++ b/cmd/internal/agentcontainer/info_test.go @@ -0,0 +1,151 @@ +package agentcontainer + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func checkoutGit(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git") + cmd.Args = append(cmd.Args, "-C", dir) + cmd.Args = append(cmd.Args, args...) + cmd.Env = append(infoGitEnv(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull) + raw, err := cmd.CombinedOutput() + require.NoError(t, err, string(raw)) + return strings.TrimSpace(string(raw)) +} + +func TestContainerInfoCheckout(t *testing.T) { + const linkedBranch = "linked" + base := t.TempDir() + root := filepath.Join(base, "main checkout") + require.NoError(t, os.Mkdir(root, 0o700)) + checkoutGit(t, root, "init", "-b", "main") + checkoutGit(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", + "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial") + commit := checkoutGit(t, root, "rev-parse", "HEAD") + worktree := filepath.Join(base, "linked checkout") + checkoutGit(t, root, "worktree", "add", "-b", linkedBranch, worktree) + subdir := filepath.Join(worktree, "nested") + require.NoError(t, os.Mkdir(subdir, 0o700)) + detached := filepath.Join(base, "detached") + checkoutGit(t, root, "worktree", "add", "--detach", detached) + bare := filepath.Join(base, "bare.git") + checkoutGit(t, root, "clone", "--bare", root, bare) + unborn := filepath.Join(base, "unborn") + require.NoError(t, os.Mkdir(unborn, 0o700)) + checkoutGit(t, unborn, "init", "-b", "new-branch") + for _, tt := range []struct { + name, dir, root, branch, commit string + }{ + {"main", root, root, "main", commit}, + {"linked", worktree, worktree, linkedBranch, commit}, + {"subdirectory", subdir, worktree, linkedBranch, commit}, + {"detached", detached, detached, "", commit}, + {"outside", base, "", "", ""}, + {"bare repository", bare, "", "", ""}, + {"unborn", unborn, unborn, "new-branch", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Chdir(tt.dir) + t.Setenv("GIT_DIR", filepath.Join(root, ".git")) + t.Setenv("GIT_WORK_TREE", root) + info := &containerInfo{} + require.NoError(t, info.readCheckout(context.Background())) + assertInfoPointer(t, info.CheckoutRoot, tt.root) + assertInfoPointer(t, info.Branch, tt.branch) + assertInfoPointer(t, info.Commit, tt.commit) + }) + } +} + +func assertInfoPointer(t *testing.T, got *string, want string) { + t.Helper() + if want == "" { + require.Nil(t, got) + return + } + require.NotNil(t, got) + if filepath.IsAbs(want) { + resolved, err := filepath.EvalSymlinks(want) + require.NoError(t, err) + want = resolved + } + require.Equal(t, want, *got) +} + +func TestContainerInfoGitFailures(t *testing.T) { + t.Run("missing Git", func(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + info := &containerInfo{} + require.ErrorContains( + t, + info.readCheckout(context.Background()), + "resolve Git checkout", + ) + }) + t.Run("broken gitdir", func(t *testing.T) { + dir := t.TempDir() + require.NoError( + t, + os.WriteFile( + filepath.Join(dir, ".git"), + []byte("gitdir: /nonexistent-devsy-test\n"), + 0o600, + ), + ) + t.Chdir(dir) + info := &containerInfo{} + require.ErrorContains( + t, + info.readCheckout(context.Background()), + "resolve Git checkout", + ) + }) + t.Run("broken commit", func(t *testing.T) { + dir := t.TempDir() + checkoutGit(t, dir, "init", "-b", "main") + ref := filepath.Join(dir, ".git", "refs", "heads", "main") + require.NoError(t, os.WriteFile(ref, []byte(strings.Repeat("a", 40)+"\n"), 0o600)) + t.Chdir(dir) + info := &containerInfo{} + require.ErrorContains(t, info.readCheckout(context.Background()), "resolve Git commit") + }) + t.Run("cancelled", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + info := &containerInfo{} + require.ErrorContains(t, info.readCheckout(ctx), "context canceled") + }) +} + +func TestContainerInfoUnreadableGitMetadata(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read mode 000 metadata") + } + parent := t.TempDir() + checkoutGit(t, parent, "init", "-b", "parent") + for _, dir := range []string{t.TempDir(), filepath.Join(parent, "nested")} { + t.Run(filepath.Base(dir), func(t *testing.T) { + require.NoError(t, os.MkdirAll(dir, 0o700)) + checkoutGit(t, dir, "init", "-b", "main") + root, err := os.OpenRoot(dir) + require.NoError(t, err) + t.Cleanup(func() { _ = root.Close() }) + metadata, err := root.Open(".git") + require.NoError(t, err) + t.Cleanup(func() { _ = metadata.Chmod(0o700); _ = metadata.Close() }) + require.NoError(t, metadata.Chmod(0)) + t.Chdir(dir) + info := &containerInfo{} + require.ErrorContains(t, info.readCheckout(context.Background()), "Git checkout") + }) + } +} diff --git a/cmd/internal/container.go b/cmd/internal/container.go new file mode 100644 index 0000000000..dcfb76ba14 --- /dev/null +++ b/cmd/internal/container.go @@ -0,0 +1,17 @@ +package cmdinternal + +import ( + "github.com/devsy-org/devsy/cmd/flags" + "github.com/devsy-org/devsy/cmd/internal/agentcontainer" + "github.com/spf13/cobra" +) + +func NewPublicContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &cobra.Command{ + Use: "container", + Short: "Inspect the current container", + PersistentPreRunE: agentPreRunE(globalFlags), + } + cmd.AddCommand(agentcontainer.NewInfoCmd(globalFlags)) + return cmd +} diff --git a/cmd/root.go b/cmd/root.go index f51c8927d8..d5142bdb9e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -434,6 +434,7 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) update.NewUpdateCmd(), wsCmdPkg.NewWorkspaceCmd(globalFlags), cmdinternal.NewInternalCmd(globalFlags), + cmdinternal.NewPublicContainerCmd(globalFlags), ) if proEnabled() { rootCmd.AddCommand(pro.NewProCmd(globalFlags)) From 5757a3433e5897d90e9c2dca41a8414501bd3ee7 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 17:32:31 +0900 Subject: [PATCH 2/8] fix(container): use the shared table formatter for info Render plain container info with pkg/table's Field/Value table, matching workspace describe while preserving command output routing and write errors. Keep the six field values and JSON output unchanged. Updated both-entrypoint rendering assertions. Focused Go tests and command lint passed. Built-CLI fixture checks confirmed matching public/internal tables with the full commit ID retained. Live containers and IDE connections were outside this presentation-only change. Co-authored-by: Codex --- cmd/container_info_test.go | 13 ++++++------- cmd/internal/agentcontainer/info.go | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/cmd/container_info_test.go b/cmd/container_info_test.go index 97fa18fdec..5e0e3cfd04 100644 --- a/cmd/container_info_test.go +++ b/cmd/container_info_test.go @@ -89,13 +89,12 @@ func TestContainerInfoEntrypoints(t *testing.T) { }, got) text, err := executeContainerInfo(t, path, "plain") require.NoError(t, err) - require.Equal( - t, - "SSH command: ssh configured-user@test-workspace.devsy\n"+ - "SSH host: test-workspace.devsy\nRemote user: configured-user\n"+ - "Checkout root: (none)\nBranch: (none)\nCommit: (none)\n", - text, - ) + for _, value := range []string{ + "Field", "Value", "SSH command", "SSH host", "Remote user", "Checkout root", "Branch", "Commit", + "ssh configured-user@test-workspace.devsy", "(none)", + } { + require.Contains(t, text, value) + } }) } } diff --git a/cmd/internal/agentcontainer/info.go b/cmd/internal/agentcontainer/info.go index 983fa223ba..68637fce53 100644 --- a/cmd/internal/agentcontainer/info.go +++ b/cmd/internal/agentcontainer/info.go @@ -17,6 +17,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" devconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" ) @@ -100,16 +101,15 @@ func writeContainerInfo(w io.Writer, mode string, info *containerInfo) error { if mode == output.ModeJSON { return json.NewEncoder(w).Encode(info) } - _, err := fmt.Fprintf( - w, - "SSH command: %s\nSSH host: %s\nRemote user: %s\nCheckout root: %s\nBranch: %s\nCommit: %s\n", - info.SSHCommand, - info.SSHHost, - info.RemoteUser, - infoValue(info.CheckoutRoot), - infoValue(info.Branch), - infoValue(info.Commit), - ) + rows := [][]string{ + {"SSH command", info.SSHCommand}, + {"SSH host", info.SSHHost}, + {"Remote user", info.RemoteUser}, + {"Checkout root", infoValue(info.CheckoutRoot)}, + {"Branch", infoValue(info.Branch)}, + {"Commit", infoValue(info.Commit)}, + } + _, err := fmt.Fprintln(w, table.Render([]string{"Field", "Value"}, rows)) return err } From 25e7bc3ace043868e082e570bf1c1512e9bb0490 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 20:06:33 +0900 Subject: [PATCH 3/8] fix(container): recognize no-checkout diagnostics after Git trace output Match the fatal no-repository diagnostic at a line boundary instead of only at the start of stderr. Keep the existing exit-code check, metadata validation and inherited Git trace/environment behavior. Four real-Git trace regression cases fail before this change and pass after it. Targeted tests, CI-parity lint, a macOS build and 16 built-CLI combinations passed. Full race-enabled CLI tests failed in six unchanged packages; the snapshot-filtering failure also reproduces in isolation. No unrelated source changes were made. Independent code review passed. Co-authored-by: Codex --- cmd/internal/agentcontainer/info.go | 16 ++++++++++++++-- cmd/internal/agentcontainer/info_test.go | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/cmd/internal/agentcontainer/info.go b/cmd/internal/agentcontainer/info.go index 68637fce53..cf1a81285b 100644 --- a/cmd/internal/agentcontainer/info.go +++ b/cmd/internal/agentcontainer/info.go @@ -126,8 +126,7 @@ func (info *containerInfo) readCheckout(ctx context.Context) error { } inside, err := infoGit(ctx, "rev-parse", "--is-inside-work-tree") if err != nil { - if exit, ok := errors.AsType[*exec.ExitError](err); ok && exit.ExitCode() == 128 && - strings.HasPrefix(string(exit.Stderr), "fatal: not a git repository (or any ") { + if infoGitOutsideCheckout(err) { return nil } return fmt.Errorf("resolve Git checkout root: %w", err) @@ -143,6 +142,19 @@ func (info *containerInfo) readCheckout(ctx context.Context) error { return info.readGitHead(ctx) } +func infoGitOutsideCheckout(err error) bool { + exit, ok := errors.AsType[*exec.ExitError](err) + if !ok || exit.ExitCode() != 128 { + return false + } + for line := range strings.SplitSeq(string(exit.Stderr), "\n") { + if strings.HasPrefix(line, "fatal: not a git repository (or any ") { + return true + } + } + return false +} + func (info *containerInfo) readGitHead(ctx context.Context) error { ref, err := infoGit(ctx, "symbolic-ref", "--quiet", "HEAD") if err != nil && !infoGitExit(err, 1) { diff --git a/cmd/internal/agentcontainer/info_test.go b/cmd/internal/agentcontainer/info_test.go index 290974db8b..95db86c68e 100644 --- a/cmd/internal/agentcontainer/info_test.go +++ b/cmd/internal/agentcontainer/info_test.go @@ -149,3 +149,17 @@ func TestContainerInfoUnreadableGitMetadata(t *testing.T) { }) } } + +func TestContainerInfoOutsideCheckoutWithTrace(t *testing.T) { + t.Chdir(t.TempDir()) + for _, key := range []string{"GIT_TRACE", "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF"} { + t.Run(key, func(t *testing.T) { + t.Setenv(key, "1") + info := &containerInfo{} + require.NoError(t, info.readCheckout(context.Background())) + require.Nil(t, info.CheckoutRoot) + require.Nil(t, info.Branch) + require.Nil(t, info.Commit) + }) + } +} From 600bd19bc5c42f96305737c34845efd77c698870 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 22:36:53 +0900 Subject: [PATCH 4/8] refactor(container): limit info to SSH connection details Expose only the SSH command, host alias and configured remote user through both container info entrypoints. Remove Git execution and folder lookup. Add subprocess Ginkgo coverage without Git, register it in Linux CI, and add the container-terminal invocation to the existing connection guide. Focused unit tests, lint, CLI build, Ginkgo, site build and link checks pass. The old implementation fails the no-Git regression oracle. Current runtime checks use local setup fixtures; no new live Linux container run was made. Earlier full CLI tests failed in six unchanged packages. Independent code and documentation reviews found no actionable issues. Co-authored-by: Codex --- .github/workflows/pr-ci.yml | 3 + cmd/container_info_test.go | 11 +- cmd/internal/agentcontainer/info.go | 154 +--------------- cmd/internal/agentcontainer/info_test.go | 165 ------------------ e2e/e2e_suite_test.go | 1 + e2e/tests/containerinfo/container_info.go | 115 ++++++++++++ .../connect-to-a-workspace.mdx | 6 + 7 files changed, 136 insertions(+), 319 deletions(-) delete mode 100644 cmd/internal/agentcontainer/info_test.go create mode 100644 e2e/tests/containerinfo/container_info.go diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index a3dfdac753..31e9553693 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -346,6 +346,9 @@ jobs: fail-fast: false matrix: include: + - label: container-info + runner: ubuntu-latest + - label: context runner: ubuntu-latest diff --git a/cmd/container_info_test.go b/cmd/container_info_test.go index 5e0e3cfd04..a5dfa527d9 100644 --- a/cmd/container_info_test.go +++ b/cmd/container_info_test.go @@ -74,7 +74,8 @@ func executeContainerInfo(t *testing.T, path []string, format string) (string, e } func TestContainerInfoEntrypoints(t *testing.T) { - containerInfoFixture(t, infoSetupFixture) + fixture := containerInfoFixture(t, infoSetupFixture) + t.Setenv("PATH", filepath.Dir(fixture)) t.Chdir(t.TempDir()) for _, path := range containerInfoEntrypoints() { t.Run(path[0], func(t *testing.T) { @@ -85,16 +86,18 @@ func TestContainerInfoEntrypoints(t *testing.T) { require.Equal(t, map[string]any{ "sshCommand": "ssh configured-user@test-workspace.devsy", "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", - "checkoutRoot": nil, "branch": nil, "commit": nil, }, got) text, err := executeContainerInfo(t, path, "plain") require.NoError(t, err) for _, value := range []string{ - "Field", "Value", "SSH command", "SSH host", "Remote user", "Checkout root", "Branch", "Commit", - "ssh configured-user@test-workspace.devsy", "(none)", + "Field", "Value", "SSH command", "SSH host", "Remote user", + "ssh configured-user@test-workspace.devsy", } { require.Contains(t, text, value) } + for _, omitted := range []string{"Checkout root", "Branch", "Commit", "Workspace folder"} { + require.NotContains(t, text, omitted) + } }) } } diff --git a/cmd/internal/agentcontainer/info.go b/cmd/internal/agentcontainer/info.go index cf1a81285b..92322f69aa 100644 --- a/cmd/internal/agentcontainer/info.go +++ b/cmd/internal/agentcontainer/info.go @@ -8,7 +8,6 @@ import ( "io" "os" "os/exec" - "path/filepath" "strings" "al.essio.dev/pkg/shellescape" @@ -22,18 +21,15 @@ import ( ) type containerInfo struct { - SSHCommand string `json:"sshCommand"` - SSHHost string `json:"sshHost"` - RemoteUser string `json:"remoteUser"` - CheckoutRoot *string `json:"checkoutRoot"` - Branch *string `json:"branch"` - Commit *string `json:"commit"` + SSHCommand string `json:"sshCommand"` + SSHHost string `json:"sshHost"` + RemoteUser string `json:"remoteUser"` } func NewInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { return &cobra.Command{ Use: "info", - Short: "Show this container's SSH connection and current Git checkout", + Short: "Show this container's SSH connection information", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { mode, err := output.ResolveMode(globalFlags.ResultFormat) @@ -67,9 +63,6 @@ func readContainerInfo(ctx context.Context) (*containerInfo, error) { RemoteUser: devconfig.GetRemoteUser(result), } info.SSHCommand = "ssh " + shellescape.Quote(info.RemoteUser+"@"+info.SSHHost) - if err := info.readCheckout(ctx); err != nil { - return nil, err - } return info, nil } @@ -105,146 +98,7 @@ func writeContainerInfo(w io.Writer, mode string, info *containerInfo) error { {"SSH command", info.SSHCommand}, {"SSH host", info.SSHHost}, {"Remote user", info.RemoteUser}, - {"Checkout root", infoValue(info.CheckoutRoot)}, - {"Branch", infoValue(info.Branch)}, - {"Commit", infoValue(info.Commit)}, } _, err := fmt.Fprintln(w, table.Render([]string{"Field", "Value"}, rows)) return err } - -func infoValue(value *string) string { - if value == nil { - return "(none)" - } - return *value -} - -func (info *containerInfo) readCheckout(ctx context.Context) error { - if err := validateCheckoutMetadata(ctx); err != nil { - return err - } - inside, err := infoGit(ctx, "rev-parse", "--is-inside-work-tree") - if err != nil { - if infoGitOutsideCheckout(err) { - return nil - } - return fmt.Errorf("resolve Git checkout root: %w", err) - } - if inside == "false" { - return nil - } - root, err := infoGit(ctx, "rev-parse", "--show-toplevel") - if err != nil { - return fmt.Errorf("resolve Git checkout root: %w", err) - } - info.CheckoutRoot = &root - return info.readGitHead(ctx) -} - -func infoGitOutsideCheckout(err error) bool { - exit, ok := errors.AsType[*exec.ExitError](err) - if !ok || exit.ExitCode() != 128 { - return false - } - for line := range strings.SplitSeq(string(exit.Stderr), "\n") { - if strings.HasPrefix(line, "fatal: not a git repository (or any ") { - return true - } - } - return false -} - -func (info *containerInfo) readGitHead(ctx context.Context) error { - ref, err := infoGit(ctx, "symbolic-ref", "--quiet", "HEAD") - if err != nil && !infoGitExit(err, 1) { - return fmt.Errorf("resolve Git branch: %w", err) - } - if err == nil { - branch := strings.TrimPrefix(ref, "refs/heads/") - info.Branch = &branch - } - commit, err := infoGit(ctx, "rev-parse", "--verify", "HEAD^{commit}") - if err != nil { - if info.Branch != nil { - _, refErr := infoGit(ctx, "show-ref", "--verify", "--quiet", ref) - if infoGitExit(refErr, 1) { - return nil - } - } - return fmt.Errorf("resolve Git commit: %w", err) - } - info.Commit = &commit - return nil -} - -func infoGit(ctx context.Context, args ...string) (string, error) { - cmd := exec.CommandContext(ctx, "git") - cmd.Args = append(cmd.Args, args...) - cmd.Env = infoGitEnv() - raw, err := cmd.Output() - value := strings.TrimSuffix(string(raw), "\n") - if err != nil { - if exit, ok := errors.AsType[*exec.ExitError](err); ok { - return value, fmt.Errorf( - "git %s: %w: %s", - strings.Join(args, " "), - err, - strings.TrimSpace(string(exit.Stderr)), - ) - } - return value, fmt.Errorf("git %s: %w", strings.Join(args, " "), err) - } - return value, nil -} - -func infoGitExit(err error, code int) bool { - exit, ok := errors.AsType[*exec.ExitError](err) - return ok && exit.ExitCode() == code -} - -func infoGitEnv() []string { - env := make([]string, 0, len(os.Environ())+1) - for _, entry := range os.Environ() { - key, _, _ := strings.Cut(entry, "=") - switch key { - case "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_PREFIX", "LC_ALL": - continue - } - env = append(env, entry) - } - return append(env, "LC_ALL=C") -} - -func validateCheckoutMetadata(ctx context.Context) error { - metadata, err := findCheckoutMetadata() - if err != nil || metadata == "" { - return err - } - if _, err := infoGit(ctx, "rev-parse", "--resolve-git-dir", metadata); err != nil { - return fmt.Errorf("resolve Git checkout metadata: %w", err) - } - return nil -} - -func findCheckoutMetadata() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", fmt.Errorf("resolve Git checkout directory: %w", err) - } - for { - metadata := filepath.Join(dir, ".git") - _, err := os.Lstat(metadata) - if err == nil { - return metadata, nil - } - if !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("inspect Git checkout metadata: %w", err) - } - parent := filepath.Dir(dir) - if parent == dir { - return "", nil - } - dir = parent - } -} diff --git a/cmd/internal/agentcontainer/info_test.go b/cmd/internal/agentcontainer/info_test.go deleted file mode 100644 index 95db86c68e..0000000000 --- a/cmd/internal/agentcontainer/info_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package agentcontainer - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/stretchr/testify/require" -) - -func checkoutGit(t *testing.T, dir string, args ...string) string { - t.Helper() - cmd := exec.Command("git") - cmd.Args = append(cmd.Args, "-C", dir) - cmd.Args = append(cmd.Args, args...) - cmd.Env = append(infoGitEnv(), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL="+os.DevNull) - raw, err := cmd.CombinedOutput() - require.NoError(t, err, string(raw)) - return strings.TrimSpace(string(raw)) -} - -func TestContainerInfoCheckout(t *testing.T) { - const linkedBranch = "linked" - base := t.TempDir() - root := filepath.Join(base, "main checkout") - require.NoError(t, os.Mkdir(root, 0o700)) - checkoutGit(t, root, "init", "-b", "main") - checkoutGit(t, root, "-c", "user.name=Test", "-c", "user.email=test@example.invalid", - "-c", "commit.gpgsign=false", "commit", "--allow-empty", "-m", "initial") - commit := checkoutGit(t, root, "rev-parse", "HEAD") - worktree := filepath.Join(base, "linked checkout") - checkoutGit(t, root, "worktree", "add", "-b", linkedBranch, worktree) - subdir := filepath.Join(worktree, "nested") - require.NoError(t, os.Mkdir(subdir, 0o700)) - detached := filepath.Join(base, "detached") - checkoutGit(t, root, "worktree", "add", "--detach", detached) - bare := filepath.Join(base, "bare.git") - checkoutGit(t, root, "clone", "--bare", root, bare) - unborn := filepath.Join(base, "unborn") - require.NoError(t, os.Mkdir(unborn, 0o700)) - checkoutGit(t, unborn, "init", "-b", "new-branch") - for _, tt := range []struct { - name, dir, root, branch, commit string - }{ - {"main", root, root, "main", commit}, - {"linked", worktree, worktree, linkedBranch, commit}, - {"subdirectory", subdir, worktree, linkedBranch, commit}, - {"detached", detached, detached, "", commit}, - {"outside", base, "", "", ""}, - {"bare repository", bare, "", "", ""}, - {"unborn", unborn, unborn, "new-branch", ""}, - } { - t.Run(tt.name, func(t *testing.T) { - t.Chdir(tt.dir) - t.Setenv("GIT_DIR", filepath.Join(root, ".git")) - t.Setenv("GIT_WORK_TREE", root) - info := &containerInfo{} - require.NoError(t, info.readCheckout(context.Background())) - assertInfoPointer(t, info.CheckoutRoot, tt.root) - assertInfoPointer(t, info.Branch, tt.branch) - assertInfoPointer(t, info.Commit, tt.commit) - }) - } -} - -func assertInfoPointer(t *testing.T, got *string, want string) { - t.Helper() - if want == "" { - require.Nil(t, got) - return - } - require.NotNil(t, got) - if filepath.IsAbs(want) { - resolved, err := filepath.EvalSymlinks(want) - require.NoError(t, err) - want = resolved - } - require.Equal(t, want, *got) -} - -func TestContainerInfoGitFailures(t *testing.T) { - t.Run("missing Git", func(t *testing.T) { - t.Setenv("PATH", t.TempDir()) - info := &containerInfo{} - require.ErrorContains( - t, - info.readCheckout(context.Background()), - "resolve Git checkout", - ) - }) - t.Run("broken gitdir", func(t *testing.T) { - dir := t.TempDir() - require.NoError( - t, - os.WriteFile( - filepath.Join(dir, ".git"), - []byte("gitdir: /nonexistent-devsy-test\n"), - 0o600, - ), - ) - t.Chdir(dir) - info := &containerInfo{} - require.ErrorContains( - t, - info.readCheckout(context.Background()), - "resolve Git checkout", - ) - }) - t.Run("broken commit", func(t *testing.T) { - dir := t.TempDir() - checkoutGit(t, dir, "init", "-b", "main") - ref := filepath.Join(dir, ".git", "refs", "heads", "main") - require.NoError(t, os.WriteFile(ref, []byte(strings.Repeat("a", 40)+"\n"), 0o600)) - t.Chdir(dir) - info := &containerInfo{} - require.ErrorContains(t, info.readCheckout(context.Background()), "resolve Git commit") - }) - t.Run("cancelled", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - info := &containerInfo{} - require.ErrorContains(t, info.readCheckout(ctx), "context canceled") - }) -} - -func TestContainerInfoUnreadableGitMetadata(t *testing.T) { - if os.Geteuid() == 0 { - t.Skip("root can read mode 000 metadata") - } - parent := t.TempDir() - checkoutGit(t, parent, "init", "-b", "parent") - for _, dir := range []string{t.TempDir(), filepath.Join(parent, "nested")} { - t.Run(filepath.Base(dir), func(t *testing.T) { - require.NoError(t, os.MkdirAll(dir, 0o700)) - checkoutGit(t, dir, "init", "-b", "main") - root, err := os.OpenRoot(dir) - require.NoError(t, err) - t.Cleanup(func() { _ = root.Close() }) - metadata, err := root.Open(".git") - require.NoError(t, err) - t.Cleanup(func() { _ = metadata.Chmod(0o700); _ = metadata.Close() }) - require.NoError(t, metadata.Chmod(0)) - t.Chdir(dir) - info := &containerInfo{} - require.ErrorContains(t, info.readCheckout(context.Background()), "Git checkout") - }) - } -} - -func TestContainerInfoOutsideCheckoutWithTrace(t *testing.T) { - t.Chdir(t.TempDir()) - for _, key := range []string{"GIT_TRACE", "GIT_TRACE2", "GIT_TRACE2_EVENT", "GIT_TRACE2_PERF"} { - t.Run(key, func(t *testing.T) { - t.Setenv(key, "1") - info := &containerInfo{} - require.NoError(t, info.readCheckout(context.Background())) - require.Nil(t, info.CheckoutRoot) - require.Nil(t, info.Branch) - require.Nil(t, info.Commit) - }) - } -} diff --git a/e2e/e2e_suite_test.go b/e2e/e2e_suite_test.go index dd956526ac..66283f19b3 100644 --- a/e2e/e2e_suite_test.go +++ b/e2e/e2e_suite_test.go @@ -14,6 +14,7 @@ import ( _ "github.com/devsy-org/devsy/e2e/tests/ci" _ "github.com/devsy-org/devsy/e2e/tests/configapply" _ "github.com/devsy-org/devsy/e2e/tests/configread" + _ "github.com/devsy-org/devsy/e2e/tests/containerinfo" _ "github.com/devsy-org/devsy/e2e/tests/context" _ "github.com/devsy-org/devsy/e2e/tests/delivery" _ "github.com/devsy-org/devsy/e2e/tests/dockerbuildkit" diff --git a/e2e/tests/containerinfo/container_info.go b/e2e/tests/containerinfo/container_info.go new file mode 100644 index 0000000000..1a0928d59e --- /dev/null +++ b/e2e/tests/containerinfo/container_info.go @@ -0,0 +1,115 @@ +package containerinfo + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "al.essio.dev/pkg/shellescape" + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/config" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + containerCommand = "container" + infoCommand = "info" +) + +var _ = ginkgo.Describe("container connection information", ginkgo.Label("container-info"), func() { + ginkgo.It( + "should expose only configured SSH information through both entrypoints without Git", + ginkgo.SpecTimeout(framework.TimeoutShort()), + func(ctx context.Context) { + t := ginkgo.GinkgoT() + dir := t.TempDir() + fixture := filepath.Join(dir, "setup-result.json") + framework.ExpectNoError( + os.WriteFile( + fixture, + []byte( + `{ + "MergedConfig":{"remoteUser":"configured-user","remoteEnv":{"TOKEN":"do-not-output"}}, + "ContainerDetails":{"Config":{"User":"image-user"}} +}`, + ), + 0o600, + ), + ) + script, err := os.CreateTemp(dir, "setup-reader-*") + framework.ExpectNoError(err) + _, err = script.WriteString( + "#!/bin/sh\nexec /bin/cat " + shellescape.Quote(fixture) + "\n", + ) + framework.ExpectNoError(err) + framework.ExpectNoError(script.Chmod(0o700)) + framework.ExpectNoError(script.Close()) + framework.ExpectNoError(os.Symlink(script.Name(), filepath.Join(dir, "sh"))) + t.Setenv("PATH", dir) + t.Setenv(config.EnvWorkspaceID, "test-workspace") + t.Setenv("USER", "wrong-os-user") + t.Setenv("LOGNAME", "wrong-os-user") + t.Setenv("DEVSY_UI", "") + cwd, err := os.Getwd() + framework.ExpectNoError(err) + f := framework.NewDefaultFramework(filepath.Join(cwd, "bin")) + for _, entry := range [][]string{ + {containerCommand, infoCommand}, + {"internal", "agent", containerCommand, infoCommand}, + } { + args := append( + entry, + "--home", + t.TempDir(), + "--context", + "not-configured", + "--provider", + "not-configured", + "--result-format", + "json", + ) + stdout, stderr, err := f.ExecCommandCapture(ctx, args) + framework.ExpectNoError(err) + gomega.Expect(stderr).To(gomega.BeEmpty()) + var got map[string]any + framework.ExpectNoError(json.Unmarshal([]byte(stdout), &got)) + gomega.Expect(got).To(gomega.Equal(map[string]any{ + "sshCommand": "ssh configured-user@test-workspace.devsy", + "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", + })) + args[len(args)-1] = "plain" + stdout, stderr, err = f.ExecCommandCapture(ctx, args) + framework.ExpectNoError(err) + gomega.Expect(stderr).To(gomega.BeEmpty()) + for _, value := range []string{ + "Field", "Value", "SSH command", "SSH host", "Remote user", + "ssh configured-user@test-workspace.devsy", + } { + gomega.Expect(stdout).To(gomega.ContainSubstring(value)) + } + for _, omitted := range []string{ + "Checkout root", "Branch", "Commit", "Workspace folder", "do-not-output", "wrong-os-user", + } { + gomega.Expect(stdout).NotTo(gomega.ContainSubstring(omitted)) + } + } + t.Setenv(config.EnvWorkspaceID, "") + stdout, stderr, err := f.ExecCommandCapture( + ctx, + []string{ + containerCommand, + "info", + "--home", + t.TempDir(), + "--result-format", + "json", + }, + ) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(stdout).To(gomega.BeEmpty()) + gomega.Expect(stderr).To(gomega.ContainSubstring(config.EnvWorkspaceID)) + }, + ) +}) diff --git a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx index afe4241e81..72acb0f053 100644 --- a/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-in-workspaces/connect-to-a-workspace.mdx @@ -59,6 +59,12 @@ devsy workspace ssh my-workspace devsy workspace ssh my-workspace --command "echo Hello World" ``` +If you are already inside a Devsy container and need the host and remote user for a manually configured IDE connection, run this from the container terminal: + +```sh +devsy container info +``` + ## IDE options Each IDE has options such as the version. List and change them with: From f30f5b6d1539a3787f8b4a04d415062c5ead4dc2 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 23:48:50 +0900 Subject: [PATCH 5/8] ci: run container info in the existing integration job Select the container info spec with the existing integration label and remove its dedicated matrix entry. Keep the container-info label for focused local runs without starting another CI job. Lint and the focused Ginkgo spec pass. An integration-label dry run selects both the existing spec and container info. Co-authored-by: Codex --- .github/workflows/pr-ci.yml | 3 --- e2e/tests/containerinfo/container_info.go | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 31e9553693..a3dfdac753 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -346,9 +346,6 @@ jobs: fail-fast: false matrix: include: - - label: container-info - runner: ubuntu-latest - - label: context runner: ubuntu-latest diff --git a/e2e/tests/containerinfo/container_info.go b/e2e/tests/containerinfo/container_info.go index 1a0928d59e..e15006a83e 100644 --- a/e2e/tests/containerinfo/container_info.go +++ b/e2e/tests/containerinfo/container_info.go @@ -22,6 +22,7 @@ var _ = ginkgo.Describe("container connection information", ginkgo.Label("contai ginkgo.It( "should expose only configured SSH information through both entrypoints without Git", ginkgo.SpecTimeout(framework.TimeoutShort()), + ginkgo.Label("integration"), func(ctx context.Context) { t := ginkgo.GinkgoT() dir := t.TempDir() From ac873bb13aca722572a2634fa8231157cfe9f5d0 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 23:56:04 +0900 Subject: [PATCH 6/8] refactor(container): simplify command constructor name Rename NewPublicContainerCmd to NewContainerCmd and update its root registration. The internal agent constructor remains in its own package. Co-authored-by: Codex --- cmd/internal/container.go | 2 +- cmd/root.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/internal/container.go b/cmd/internal/container.go index dcfb76ba14..a2525ceec8 100644 --- a/cmd/internal/container.go +++ b/cmd/internal/container.go @@ -6,7 +6,7 @@ import ( "github.com/spf13/cobra" ) -func NewPublicContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { +func NewContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd := &cobra.Command{ Use: "container", Short: "Inspect the current container", diff --git a/cmd/root.go b/cmd/root.go index d5142bdb9e..f28e2e0185 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -434,7 +434,7 @@ func registerSubcommands(rootCmd *cobra.Command, globalFlags *flags.GlobalFlags) update.NewUpdateCmd(), wsCmdPkg.NewWorkspaceCmd(globalFlags), cmdinternal.NewInternalCmd(globalFlags), - cmdinternal.NewPublicContainerCmd(globalFlags), + cmdinternal.NewContainerCmd(globalFlags), ) if proEnabled() { rootCmd.AddCommand(pro.NewProCmd(globalFlags)) From 48b88763e9753cb9b6e8384dff1b64d5d3c8dbe6 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Mon, 19 Oct 2026 23:58:51 +0900 Subject: [PATCH 7/8] test(container): focus assertions on SSH connection output Name integration coverage for its observable connection output. Exercise both entrypoints with the setup reader fixture and retain configured-user, output-shape, secret omission and setup-failure checks. Focused unit tests, lint and the container info integration spec pass. Co-authored-by: Codex --- cmd/container_info_test.go | 6 ++---- e2e/tests/containerinfo/container_info.go | 6 +++--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd/container_info_test.go b/cmd/container_info_test.go index a5dfa527d9..e07982fc35 100644 --- a/cmd/container_info_test.go +++ b/cmd/container_info_test.go @@ -74,9 +74,7 @@ func executeContainerInfo(t *testing.T, path []string, format string) (string, e } func TestContainerInfoEntrypoints(t *testing.T) { - fixture := containerInfoFixture(t, infoSetupFixture) - t.Setenv("PATH", filepath.Dir(fixture)) - t.Chdir(t.TempDir()) + containerInfoFixture(t, infoSetupFixture) for _, path := range containerInfoEntrypoints() { t.Run(path[0], func(t *testing.T) { raw, err := executeContainerInfo(t, path, "json") @@ -95,7 +93,7 @@ func TestContainerInfoEntrypoints(t *testing.T) { } { require.Contains(t, text, value) } - for _, omitted := range []string{"Checkout root", "Branch", "Commit", "Workspace folder"} { + for _, omitted := range []string{"do-not-output", "wrong-os-user"} { require.NotContains(t, text, omitted) } }) diff --git a/e2e/tests/containerinfo/container_info.go b/e2e/tests/containerinfo/container_info.go index e15006a83e..8af218e4b8 100644 --- a/e2e/tests/containerinfo/container_info.go +++ b/e2e/tests/containerinfo/container_info.go @@ -20,7 +20,7 @@ const ( var _ = ginkgo.Describe("container connection information", ginkgo.Label("container-info"), func() { ginkgo.It( - "should expose only configured SSH information through both entrypoints without Git", + "should show the container's SSH connection details", ginkgo.SpecTimeout(framework.TimeoutShort()), ginkgo.Label("integration"), func(ctx context.Context) { @@ -48,7 +48,7 @@ var _ = ginkgo.Describe("container connection information", ginkgo.Label("contai framework.ExpectNoError(script.Chmod(0o700)) framework.ExpectNoError(script.Close()) framework.ExpectNoError(os.Symlink(script.Name(), filepath.Join(dir, "sh"))) - t.Setenv("PATH", dir) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv(config.EnvWorkspaceID, "test-workspace") t.Setenv("USER", "wrong-os-user") t.Setenv("LOGNAME", "wrong-os-user") @@ -91,7 +91,7 @@ var _ = ginkgo.Describe("container connection information", ginkgo.Label("contai gomega.Expect(stdout).To(gomega.ContainSubstring(value)) } for _, omitted := range []string{ - "Checkout root", "Branch", "Commit", "Workspace folder", "do-not-output", "wrong-os-user", + "do-not-output", "wrong-os-user", } { gomega.Expect(stdout).NotTo(gomega.ContainSubstring(omitted)) } From 4e344360c80c77b2596f1be758214a8fb7501165 Mon Sep 17 00:00:00 2001 From: Koji AGAWA Date: Tue, 20 Oct 2026 00:02:59 +0900 Subject: [PATCH 8/8] refactor(container): expose info through the public command Keep the SSH information command under the public container tree and place its implementation alongside that registration. Use a private constructor and focus CLI tests on the public entrypoint. Focused unit tests, lint and the container info Ginkgo spec pass. Co-authored-by: Codex --- cmd/container_info_test.go | 73 +++++++---------- cmd/internal/agentcontainer/container.go | 1 - cmd/internal/container.go | 3 +- .../info.go => container_info.go} | 4 +- e2e/tests/containerinfo/container_info.go | 81 +++++++++---------- 5 files changed, 68 insertions(+), 94 deletions(-) rename cmd/internal/{agentcontainer/info.go => container_info.go} (96%) diff --git a/cmd/container_info_test.go b/cmd/container_info_test.go index e07982fc35..d70d2ed95d 100644 --- a/cmd/container_info_test.go +++ b/cmd/container_info_test.go @@ -15,8 +15,6 @@ import ( ) const ( - infoContainerCommand = "container" - infoCommand = "info" infoWorkspaceID = "test-workspace" infoIncompleteResult = "incomplete or failed" infoSetupFixture = `{ @@ -50,15 +48,15 @@ func containerInfoFixture(t *testing.T, result string) string { return fixture } -func executeContainerInfo(t *testing.T, path []string, format string) (string, error) { +func executeContainerInfo(t *testing.T, format string) (string, error) { t.Helper() root, _ := BuildRoot() var out bytes.Buffer root.SetOut(&out) root.SetErr(&out) - args := append([]string{}, path...) - args = append( - args, + args := []string{ + "container", + "info", "--home", t.TempDir(), "--context", @@ -67,36 +65,32 @@ func executeContainerInfo(t *testing.T, path []string, format string) (string, e "not-configured", "--result-format", format, - ) + } root.SetArgs(args) err := root.Execute() return out.String(), err } -func TestContainerInfoEntrypoints(t *testing.T) { +func TestContainerInfo(t *testing.T) { containerInfoFixture(t, infoSetupFixture) - for _, path := range containerInfoEntrypoints() { - t.Run(path[0], func(t *testing.T) { - raw, err := executeContainerInfo(t, path, "json") - require.NoError(t, err) - var got map[string]any - require.NoError(t, json.Unmarshal([]byte(raw), &got)) - require.Equal(t, map[string]any{ - "sshCommand": "ssh configured-user@test-workspace.devsy", - "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", - }, got) - text, err := executeContainerInfo(t, path, "plain") - require.NoError(t, err) - for _, value := range []string{ - "Field", "Value", "SSH command", "SSH host", "Remote user", - "ssh configured-user@test-workspace.devsy", - } { - require.Contains(t, text, value) - } - for _, omitted := range []string{"do-not-output", "wrong-os-user"} { - require.NotContains(t, text, omitted) - } - }) + raw, err := executeContainerInfo(t, "json") + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal([]byte(raw), &got)) + require.Equal(t, map[string]any{ + "sshCommand": "ssh configured-user@test-workspace.devsy", + "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", + }, got) + text, err := executeContainerInfo(t, "plain") + require.NoError(t, err) + for _, value := range []string{ + "Field", "Value", "SSH command", "SSH host", "Remote user", + "ssh configured-user@test-workspace.devsy", + } { + require.Contains(t, text, value) + } + for _, omitted := range []string{"do-not-output", "wrong-os-user"} { + require.NotContains(t, text, omitted) } } @@ -115,11 +109,9 @@ func TestContainerInfoSetupFailures(t *testing.T) { t.Run(tt.name, func(t *testing.T) { containerInfoFixture(t, tt.result) t.Setenv(config.EnvWorkspaceID, tt.workspaceID) - for _, path := range containerInfoEntrypoints() { - raw, err := executeContainerInfo(t, path, "json") - require.ErrorContains(t, err, tt.want) - require.Empty(t, raw) - } + raw, err := executeContainerInfo(t, "json") + require.ErrorContains(t, err, tt.want) + require.Empty(t, raw) }) } } @@ -127,22 +119,15 @@ func TestContainerInfoSetupFailures(t *testing.T) { func TestContainerInfoReadFailure(t *testing.T) { fixture := containerInfoFixture(t, infoSetupFixture) require.NoError(t, os.Remove(fixture)) - raw, err := executeContainerInfo(t, []string{infoContainerCommand, infoCommand}, "json") + raw, err := executeContainerInfo(t, "json") require.ErrorContains(t, err, "read selected container setup result") require.Empty(t, raw) } -func containerInfoEntrypoints() [][]string { - return [][]string{ - {infoContainerCommand, infoCommand}, - {"internal", "agent", infoContainerCommand, infoCommand}, - } -} - func TestContainerInfoFailureRendering(t *testing.T) { fixture := containerInfoFixture(t, infoSetupFixture) require.NoError(t, os.Remove(fixture)) - _, err := executeContainerInfo(t, containerInfoEntrypoints()[0], "json") + _, err := executeContainerInfo(t, "json") require.Error(t, err) for _, machine := range []bool{false, true} { reader, writer, pipeErr := os.Pipe() diff --git a/cmd/internal/agentcontainer/container.go b/cmd/internal/agentcontainer/container.go index 7f8dd68c38..57a130a973 100644 --- a/cmd/internal/agentcontainer/container.go +++ b/cmd/internal/agentcontainer/container.go @@ -29,6 +29,5 @@ func NewContainerCmd(flags *flags.GlobalFlags) *cobra.Command { containerCmd.AddCommand(NewCredentialsServerCmd(flags)) containerCmd.AddCommand(NewSetupDevsyPlatformAccessCmd(flags)) containerCmd.AddCommand(NewSSHServerCmd(flags)) - containerCmd.AddCommand(NewInfoCmd(flags)) return containerCmd } diff --git a/cmd/internal/container.go b/cmd/internal/container.go index a2525ceec8..5436baed2a 100644 --- a/cmd/internal/container.go +++ b/cmd/internal/container.go @@ -2,7 +2,6 @@ package cmdinternal import ( "github.com/devsy-org/devsy/cmd/flags" - "github.com/devsy-org/devsy/cmd/internal/agentcontainer" "github.com/spf13/cobra" ) @@ -12,6 +11,6 @@ func NewContainerCmd(globalFlags *flags.GlobalFlags) *cobra.Command { Short: "Inspect the current container", PersistentPreRunE: agentPreRunE(globalFlags), } - cmd.AddCommand(agentcontainer.NewInfoCmd(globalFlags)) + cmd.AddCommand(newContainerInfoCmd(globalFlags)) return cmd } diff --git a/cmd/internal/agentcontainer/info.go b/cmd/internal/container_info.go similarity index 96% rename from cmd/internal/agentcontainer/info.go rename to cmd/internal/container_info.go index 92322f69aa..890eb06056 100644 --- a/cmd/internal/agentcontainer/info.go +++ b/cmd/internal/container_info.go @@ -1,4 +1,4 @@ -package agentcontainer +package cmdinternal import ( "context" @@ -26,7 +26,7 @@ type containerInfo struct { RemoteUser string `json:"remoteUser"` } -func NewInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { +func newContainerInfoCmd(globalFlags *flags.GlobalFlags) *cobra.Command { return &cobra.Command{ Use: "info", Short: "Show this container's SSH connection information", diff --git a/e2e/tests/containerinfo/container_info.go b/e2e/tests/containerinfo/container_info.go index 8af218e4b8..3172fc08a6 100644 --- a/e2e/tests/containerinfo/container_info.go +++ b/e2e/tests/containerinfo/container_info.go @@ -13,11 +13,6 @@ import ( "github.com/onsi/gomega" ) -const ( - containerCommand = "container" - infoCommand = "info" -) - var _ = ginkgo.Describe("container connection information", ginkgo.Label("container-info"), func() { ginkgo.It( "should show the container's SSH connection details", @@ -56,51 +51,47 @@ var _ = ginkgo.Describe("container connection information", ginkgo.Label("contai cwd, err := os.Getwd() framework.ExpectNoError(err) f := framework.NewDefaultFramework(filepath.Join(cwd, "bin")) - for _, entry := range [][]string{ - {containerCommand, infoCommand}, - {"internal", "agent", containerCommand, infoCommand}, + args := []string{ + "container", + "info", + "--home", + t.TempDir(), + "--context", + "not-configured", + "--provider", + "not-configured", + "--result-format", + "json", + } + stdout, stderr, err := f.ExecCommandCapture(ctx, args) + framework.ExpectNoError(err) + gomega.Expect(stderr).To(gomega.BeEmpty()) + var got map[string]any + framework.ExpectNoError(json.Unmarshal([]byte(stdout), &got)) + gomega.Expect(got).To(gomega.Equal(map[string]any{ + "sshCommand": "ssh configured-user@test-workspace.devsy", + "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", + })) + args[len(args)-1] = "plain" + stdout, stderr, err = f.ExecCommandCapture(ctx, args) + framework.ExpectNoError(err) + gomega.Expect(stderr).To(gomega.BeEmpty()) + for _, value := range []string{ + "Field", "Value", "SSH command", "SSH host", "Remote user", + "ssh configured-user@test-workspace.devsy", } { - args := append( - entry, - "--home", - t.TempDir(), - "--context", - "not-configured", - "--provider", - "not-configured", - "--result-format", - "json", - ) - stdout, stderr, err := f.ExecCommandCapture(ctx, args) - framework.ExpectNoError(err) - gomega.Expect(stderr).To(gomega.BeEmpty()) - var got map[string]any - framework.ExpectNoError(json.Unmarshal([]byte(stdout), &got)) - gomega.Expect(got).To(gomega.Equal(map[string]any{ - "sshCommand": "ssh configured-user@test-workspace.devsy", - "sshHost": "test-workspace.devsy", "remoteUser": "configured-user", - })) - args[len(args)-1] = "plain" - stdout, stderr, err = f.ExecCommandCapture(ctx, args) - framework.ExpectNoError(err) - gomega.Expect(stderr).To(gomega.BeEmpty()) - for _, value := range []string{ - "Field", "Value", "SSH command", "SSH host", "Remote user", - "ssh configured-user@test-workspace.devsy", - } { - gomega.Expect(stdout).To(gomega.ContainSubstring(value)) - } - for _, omitted := range []string{ - "do-not-output", "wrong-os-user", - } { - gomega.Expect(stdout).NotTo(gomega.ContainSubstring(omitted)) - } + gomega.Expect(stdout).To(gomega.ContainSubstring(value)) + } + for _, omitted := range []string{ + "do-not-output", "wrong-os-user", + } { + gomega.Expect(stdout).NotTo(gomega.ContainSubstring(omitted)) } t.Setenv(config.EnvWorkspaceID, "") - stdout, stderr, err := f.ExecCommandCapture( + stdout, stderr, err = f.ExecCommandCapture( ctx, []string{ - containerCommand, + "container", "info", "--home", t.TempDir(),