Skip to content

Commit 31e21fc

Browse files
committed
in-workspace detction, explicit api token generation (for printed URIs)
1 parent c7dba53 commit 31e21fc

File tree

11 files changed

+492
-338
lines changed

11 files changed

+492
-338
lines changed

agent/agent_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -926,7 +926,7 @@ func TestAgent_EnvironmentVariableExpansion(t *testing.T) {
926926
func TestAgent_CoderEnvVars(t *testing.T) {
927927
t.Parallel()
928928

929-
for _, key := range []string{"CODER"} {
929+
for _, key := range []string{"CODER", "CODER_WORKSPACE_NAME", "CODER_WORKSPACE_AGENT_NAME"} {
930930
key := key
931931
t.Run(key, func(t *testing.T) {
932932
t.Parallel()

agent/agentssh/agentssh.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,8 @@ func (s *Server) CreateCommand(ctx context.Context, script string, env []string)
659659
// Set environment variables reliable detection of being inside a
660660
// Coder workspace.
661661
cmd.Env = append(cmd.Env, "CODER=true")
662+
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_NAME="+manifest.WorkspaceName)
663+
cmd.Env = append(cmd.Env, "CODER_WORKSPACE_AGENT_NAME="+manifest.AgentName)
662664
cmd.Env = append(cmd.Env, fmt.Sprintf("USER=%s", username))
663665
// Git on Windows resolves with UNIX-style paths.
664666
// If using backslashes, it's unable to find the executable.

agent/proto/agent.pb.go

Lines changed: 295 additions & 274 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent/proto/agent.proto

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,10 @@ message WorkspaceAgentMetadata {
7575

7676
message Manifest {
7777
bytes agent_id = 1;
78+
string agent_name = 15;
7879
string owner_username = 13;
7980
bytes workspace_id = 14;
81+
string workspace_name = 16;
8082
uint32 git_auth_configs = 2;
8183
map<string, string> environment_variables = 3;
8284
string directory = 4;

cli/open.go

Lines changed: 113 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"fmt"
66
"net/url"
7+
"path/filepath"
78
"strings"
89

910
"github.com/skratchdot/open-golang/open"
@@ -28,111 +29,174 @@ func (r *RootCmd) open() *clibase.Cmd {
2829
return cmd
2930
}
3031

32+
const vscodeDesktopName = "VS Code Desktop"
33+
3134
func (r *RootCmd) openVSCode() *clibase.Cmd {
32-
var testNoOpen bool
35+
var (
36+
generateToken bool
37+
testNoOpen bool
38+
)
3339

3440
client := new(codersdk.Client)
3541
cmd := &clibase.Cmd{
3642
Annotations: workspaceCommand,
3743
Use: "vscode <workspace> [<directory in workspace>]",
38-
Short: "Open a workspace in Visual Studio Code",
44+
Short: "Open a workspace in Visual Studio Code.",
3945
Middleware: clibase.Chain(
40-
clibase.RequireRangeArgs(1, -1),
46+
clibase.RequireRangeArgs(1, 2),
4147
r.InitClient(client),
4248
),
4349
Handler: func(inv *clibase.Invocation) error {
4450
ctx, cancel := context.WithCancel(inv.Context())
4551
defer cancel()
4652

47-
// Prepare an API key. This is for automagical configuration of
48-
// VS Code, however, we could try to probe VS Code settings to see
49-
// if the current configuration is valid. Future improvement idea.
50-
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
51-
if err != nil {
52-
return xerrors.Errorf("create API key: %w", err)
53-
}
53+
// Check if we're inside a workspace, and especially inside _this_
54+
// workspace so we can perform path resolution/expansion. Generally,
55+
// we know that if we're inside a workspace, `open` can't be used.
56+
insideAWorkspace := inv.Environ.Get("CODER") == "true"
57+
inWorkspaceName := inv.Environ.Get("CODER_WORKSPACE_NAME") + "." + inv.Environ.Get("CODER_WORKSPACE_AGENT_NAME")
5458

5559
// We need a started workspace to figure out e.g. expanded directory.
5660
// Pehraps the vscode-coder extension could handle this by accepting
5761
// default_directory=true, then probing the agent. Then we wouldn't
5862
// need to wait for the agent to start.
59-
workspaceName := inv.Args[0]
63+
workspaceQuery := inv.Args[0]
6064
autostart := true
61-
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceName)
65+
workspace, workspaceAgent, err := getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceQuery)
6266
if err != nil {
6367
return xerrors.Errorf("get workspace and agent: %w", err)
6468
}
6569

66-
// We could optionally add a flag to skip wait, like with SSH.
67-
wait := false
68-
for _, script := range workspaceAgent.Scripts {
69-
if script.StartBlocksLogin {
70-
wait = true
71-
break
70+
workspaceName := workspace.Name + "." + workspaceAgent.Name
71+
insideThisWorkspace := insideAWorkspace && inWorkspaceName == workspaceName
72+
73+
if !insideThisWorkspace {
74+
// We could optionally add a flag to skip wait, like with SSH.
75+
wait := false
76+
for _, script := range workspaceAgent.Scripts {
77+
if script.StartBlocksLogin {
78+
wait = true
79+
break
80+
}
7281
}
73-
}
74-
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
75-
Fetch: client.WorkspaceAgent,
76-
FetchLogs: client.WorkspaceAgentLogsAfter,
77-
Wait: wait,
78-
})
79-
if err != nil {
80-
if xerrors.Is(err, context.Canceled) {
81-
return cliui.Canceled
82+
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
83+
Fetch: client.WorkspaceAgent,
84+
FetchLogs: client.WorkspaceAgentLogsAfter,
85+
Wait: wait,
86+
})
87+
if err != nil {
88+
if xerrors.Is(err, context.Canceled) {
89+
return cliui.Canceled
90+
}
91+
return xerrors.Errorf("agent: %w", err)
8292
}
83-
return xerrors.Errorf("agent: %w", err)
84-
}
8593

86-
// If the ExpandedDirectory was initially missing, it could mean
87-
// that the agent hadn't reported it in yet. Retry once.
88-
if workspaceAgent.ExpandedDirectory == "" {
89-
autostart = false // Don't retry autostart.
90-
workspace, workspaceAgent, err = getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceName)
91-
if err != nil {
92-
return xerrors.Errorf("get workspace and agent retry: %w", err)
94+
// If the ExpandedDirectory was initially missing, it could mean
95+
// that the agent hadn't reported it in yet. Retry once.
96+
if workspaceAgent.ExpandedDirectory == "" {
97+
autostart = false // Don't retry autostart.
98+
workspace, workspaceAgent, err = getWorkspaceAndAgent(ctx, inv, client, autostart, codersdk.Me, workspaceName)
99+
if err != nil {
100+
return xerrors.Errorf("get workspace and agent retry: %w", err)
101+
}
93102
}
94103
}
95104

96-
var folder string
105+
var directory string
97106
switch {
98107
case len(inv.Args) > 1:
99-
folder = inv.Args[1]
108+
directory = inv.Args[1]
100109
// Perhaps we could SSH in to expand the directory?
101-
if strings.HasPrefix(folder, "~") {
102-
return xerrors.Errorf("folder path %q not supported, use an absolute path instead", folder)
110+
if !insideThisWorkspace && strings.HasPrefix(directory, "~") {
111+
return xerrors.Errorf("directory path %q not supported, use an absolute path instead", directory)
112+
}
113+
if insideThisWorkspace {
114+
directory, err = filepath.Abs(directory)
115+
if err != nil {
116+
return xerrors.Errorf("expand directory: %w", err)
117+
}
103118
}
104119
case workspaceAgent.ExpandedDirectory != "":
105-
folder = workspaceAgent.ExpandedDirectory
120+
directory = workspaceAgent.ExpandedDirectory
121+
}
122+
123+
u, err := url.Parse("vscode://coder.coder-remote/open")
124+
if err != nil {
125+
return xerrors.Errorf("parse vscode URI: %w", err)
106126
}
107127

108128
qp := url.Values{}
109129

110130
qp.Add("url", client.URL.String())
111-
qp.Add("token", apiKey.Key)
112131
qp.Add("owner", workspace.OwnerName)
113132
qp.Add("workspace", workspace.Name)
114133
qp.Add("agent", workspaceAgent.Name)
115-
if folder != "" {
116-
qp.Add("folder", folder)
134+
if directory != "" {
135+
qp.Add("folder", directory)
117136
}
118137

119-
uri := fmt.Sprintf("vscode://coder.coder-remote/open?%s", qp.Encode())
120-
_, _ = fmt.Fprintf(inv.Stdout, "Opening %s\n", strings.ReplaceAll(uri, apiKey.Key, "<REDACTED>"))
138+
// We always set the token if we believe we can open without
139+
// printing the URI, otherwise the token must be explicitly
140+
// requested as it will be printed in plain text.
141+
if !insideAWorkspace || generateToken {
142+
// Prepare an API key. This is for automagical configuration of
143+
// VS Code, however, if running on a local machine we could try
144+
// to probe VS Code settings to see if the current configuration
145+
// is valid. Future improvement idea.
146+
apiKey, err := client.CreateAPIKey(ctx, codersdk.Me)
147+
if err != nil {
148+
return xerrors.Errorf("create API key: %w", err)
149+
}
150+
qp.Add("token", apiKey.Key)
151+
}
152+
153+
u.RawQuery = qp.Encode()
154+
155+
openingPath := workspaceName
156+
if directory != "" {
157+
openingPath += ":" + directory
158+
}
121159

122-
if testNoOpen {
160+
if insideAWorkspace {
161+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s is not supported inside a workspace, please open the following URI on your local machine instead:\n\n", openingPath, vscodeDesktopName)
162+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
123163
return nil
164+
} else {
165+
_, _ = fmt.Fprintf(inv.Stderr, "Opening %s in %s\n", openingPath, vscodeDesktopName)
124166
}
125167

126-
err = open.Run(uri)
168+
if !testNoOpen {
169+
err = open.Run(u.String())
170+
} else {
171+
err = xerrors.New("test.no-open")
172+
}
127173
if err != nil {
128-
return xerrors.Errorf("open: %w", err)
174+
if !generateToken {
175+
qp.Del("token")
176+
u.RawQuery = qp.Encode()
177+
}
178+
179+
_, _ = fmt.Fprintf(inv.Stderr, "Could not automatically open %s in %s: %s\n", openingPath, vscodeDesktopName, err)
180+
_, _ = fmt.Fprintf(inv.Stderr, "Please open the following URI instead:\n\n")
181+
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", u.String())
182+
return nil
129183
}
130184

131185
return nil
132186
},
133187
}
134188

135189
cmd.Options = clibase.OptionSet{
190+
{
191+
Flag: "generate-token",
192+
Env: "CODER_OPEN_VSCODE_GENERATE_TOKEN",
193+
Description: fmt.Sprintf(
194+
"Generate an auth token and include it in the vscode:// URI. This is for automagical configuration of %s and not needed if already configured. "+
195+
"This flag does not need to be specified when running this command on a local machine unless automatic open fails.",
196+
vscodeDesktopName,
197+
),
198+
Value: clibase.BoolOf(&generateToken),
199+
},
136200
{
137201
Flag: "test.no-open",
138202
Description: "Don't run the open command.",

cli/open_test.go

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cli_test
33
import (
44
"context"
55
"net/url"
6-
"strings"
76
"testing"
87

98
"github.com/stretchr/testify/assert"
@@ -21,7 +20,7 @@ import (
2120
func TestOpen(t *testing.T) {
2221
t.Parallel()
2322

24-
t.Run("VSCode", func(t *testing.T) {
23+
t.Run("VS Code Local", func(t *testing.T) {
2524
t.Parallel()
2625

2726
client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
@@ -30,16 +29,69 @@ func TestOpen(t *testing.T) {
3029
return agents
3130
})
3231

32+
_ = agenttest.New(t, client.URL, agentToken)
33+
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
34+
3335
inv, root := clitest.New(t, "open", "vscode", "--test.no-open", workspace.Name)
3436
clitest.SetupConfig(t, client, root)
3537
pty := ptytest.New(t)
3638
inv.Stdin = pty.Input()
37-
inv.Stderr = pty.Output()
3839
inv.Stdout = pty.Output()
3940

41+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
42+
defer cancel()
43+
44+
cmdDone := tGo(t, func() {
45+
err := inv.WithContext(ctx).Run()
46+
assert.NoError(t, err)
47+
})
48+
49+
me, err := client.User(ctx, codersdk.Me)
50+
require.NoError(t, err)
51+
52+
// --test.no-open forces the command to print the URI.
53+
line := pty.ReadLine(ctx)
54+
u, err := url.ParseRequestURI(line)
55+
require.NoError(t, err, "line: %q", line)
56+
57+
qp := u.Query()
58+
assert.Equal(t, client.URL.String(), qp.Get("url"))
59+
assert.Equal(t, me.Username, qp.Get("owner"))
60+
assert.Equal(t, workspace.Name, qp.Get("workspace"))
61+
assert.Equal(t, "agent1", qp.Get("agent"))
62+
assert.Equal(t, "/tmp", qp.Get("folder"))
63+
assert.Equal(t, "", qp.Get("token"))
64+
65+
<-cmdDone
66+
})
67+
t.Run("VS Code Inside Workspace Prints URI", func(t *testing.T) {
68+
t.Parallel()
69+
70+
agentName := "agent1"
71+
client, workspace, agentToken := setupWorkspaceForAgent(t, func(agents []*proto.Agent) []*proto.Agent {
72+
agents[0].Directory = "/tmp"
73+
agents[0].Name = agentName
74+
return agents
75+
})
76+
4077
_ = agenttest.New(t, client.URL, agentToken)
4178
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
4279

80+
t.Log(client.SessionToken())
81+
82+
inv, root := clitest.New(t, "open", "vscode", "--generate-token", workspace.Name)
83+
clitest.SetupConfig(t, client, root)
84+
85+
t.Log(root.Session().Read())
86+
87+
pty := ptytest.New(t)
88+
inv.Stdin = pty.Input()
89+
inv.Stdout = pty.Output()
90+
91+
inv.Environ.Set("CODER", "true")
92+
inv.Environ.Set("CODER_WORKSPACE_NAME", workspace.Name)
93+
inv.Environ.Set("CODER_WORKSPACE_AGENT_NAME", agentName)
94+
4395
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
4496
defer cancel()
4597

@@ -52,22 +104,17 @@ func TestOpen(t *testing.T) {
52104
require.NoError(t, err)
53105

54106
line := pty.ReadLine(ctx)
55-
56-
// Opening vscode://coder.coder-remote/open?...
57-
parts := strings.Split(line, " ")
58-
require.Len(t, parts, 2)
59-
require.Contains(t, parts[1], "vscode://")
60-
u, err := url.ParseRequestURI(parts[1])
61-
require.NoError(t, err)
107+
u, err := url.ParseRequestURI(line)
108+
require.NoError(t, err, "line: %q", line)
62109

63110
qp := u.Query()
64111
assert.Equal(t, client.URL.String(), qp.Get("url"))
65112
assert.Equal(t, me.Username, qp.Get("owner"))
66113
assert.Equal(t, workspace.Name, qp.Get("workspace"))
67114
assert.Equal(t, "agent1", qp.Get("agent"))
68115
assert.Equal(t, "/tmp", qp.Get("folder"))
116+
assert.NotEmpty(t, qp.Get("token"))
69117

70-
cancel()
71118
<-cmdDone
72119
})
73120
}

coderd/agentapi/manifest.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,10 @@ func (a *ManifestAPI) GetManifest(ctx context.Context, _ *agentproto.GetManifest
136136

137137
return &agentproto.Manifest{
138138
AgentId: workspaceAgent.ID[:],
139+
AgentName: workspaceAgent.Name,
139140
OwnerUsername: owner.Username,
140141
WorkspaceId: workspace.ID[:],
142+
WorkspaceName: workspace.Name,
141143
GitAuthConfigs: gitAuthConfigs,
142144
EnvironmentVariables: apiAgent.EnvironmentVariables,
143145
Directory: apiAgent.Directory,

0 commit comments

Comments
 (0)