From f1cca03ed3016645a7572b01e38680d7dc971306 Mon Sep 17 00:00:00 2001 From: Edward Angert Date: Thu, 12 Jun 2025 13:56:45 -0400 Subject: [PATCH 1/3] docs: reorganize the About section (#18236) As part of an information architecture overhaul, this PR reorganizes the About section and adds a Support section (but not content to it yet) [preview](https://coder.com/docs/@docs-ia-about/about) this PR is intentionally limited in scope so that we can ship meaningful changes faster and followup PRs should include: - [ ] edit + overhaul the About page - [ ] decide on the `start` directory - [ ] ~screenshots page updates~ (this should happen July or later) redirects PR: https://github.com/coder/coder.com/pull/944 --------- Co-authored-by: EdwardAngert <17991901+EdwardAngert@users.noreply.github.com> --- CLAUDE.md | 2 +- CODE_OF_CONDUCT.md | 2 +- coderd/database/migrations/migrate_test.go | 2 +- .../contributing/CODE_OF_CONDUCT.md | 0 docs/{ => about/contributing}/CONTRIBUTING.md | 4 +- docs/about/contributing/SECURITY.md | 11 +++ docs/{ => about}/contributing/backend.md | 0 .../{ => about}/contributing/documentation.md | 0 docs/{ => about}/contributing/frontend.md | 2 +- docs/{start => about}/screenshots.md | 0 docs/{start => about}/why-coder.md | 0 docs/admin/security/index.md | 3 +- .../templates/extending-templates/modules.md | 8 +- docs/ai-coder/custom-agents.md | 4 +- docs/contributing/SECURITY.md | 4 - docs/manifest.json | 99 ++++++++++--------- docs/support/index.md | 5 + docs/{tutorials => support}/support-bundle.md | 0 docs/user-guides/workspace-access/index.md | 2 +- 19 files changed, 83 insertions(+), 65 deletions(-) rename docs/{ => about}/contributing/CODE_OF_CONDUCT.md (100%) rename docs/{ => about/contributing}/CONTRIBUTING.md (98%) create mode 100644 docs/about/contributing/SECURITY.md rename docs/{ => about}/contributing/backend.md (100%) rename docs/{ => about}/contributing/documentation.md (100%) rename docs/{ => about}/contributing/frontend.md (99%) rename docs/{start => about}/screenshots.md (100%) rename docs/{start => about}/why-coder.md (100%) delete mode 100644 docs/contributing/SECURITY.md create mode 100644 docs/support/index.md rename docs/{tutorials => support}/support-bundle.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md index 90d91c9966df7..e124df8e2d05e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,4 +101,4 @@ Read [cursor rules](.cursorrules). ## Frontend -For building Frontend refer to [this document](docs/contributing/frontend.md) +For building Frontend refer to [this document](docs/about/contributing/frontend.md) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 37dadd19667d4..6482f8c8c99f1 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,2 +1,2 @@ -[https://coder.com/docs/contributing/CODE_OF_CONDUCT](https://coder.com/docs/contributing/CODE_OF_CONDUCT) +[https://coder.com/docs/about/contributing/CODE_OF_CONDUCT](https://coder.com/docs/about/contributing/CODE_OF_CONDUCT) diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index 65dc9e6267310..cd843bd97aa7a 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -283,7 +283,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { if len(emptyTables) > 0 { t.Log("The following tables have zero rows, consider adding fixtures for them or create a full database dump:") t.Errorf("tables have zero rows: %v", emptyTables) - t.Log("See https://github.com/coder/coder/blob/main/docs/CONTRIBUTING.md#database-fixtures-for-testing-migrations for more information") + t.Log("See https://github.com/coder/coder/blob/main/docs/about/contributing/backend.md#database-fixtures-for-testing-migrations for more information") } }) diff --git a/docs/contributing/CODE_OF_CONDUCT.md b/docs/about/contributing/CODE_OF_CONDUCT.md similarity index 100% rename from docs/contributing/CODE_OF_CONDUCT.md rename to docs/about/contributing/CODE_OF_CONDUCT.md diff --git a/docs/CONTRIBUTING.md b/docs/about/contributing/CONTRIBUTING.md similarity index 98% rename from docs/CONTRIBUTING.md rename to docs/about/contributing/CONTRIBUTING.md index 3b0d14cb659f2..8f4eb518bae76 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/about/contributing/CONTRIBUTING.md @@ -143,9 +143,9 @@ channel. ## Styling -Visit our [documentation style guide](./contributing/documentation.md). +- [Documentation style guide](./documentation.md) -Frontend styling guide can be found [here](./contributing/frontend.md#styling). +- [Frontend styling guide](./frontend.md#styling) ## Reviews diff --git a/docs/about/contributing/SECURITY.md b/docs/about/contributing/SECURITY.md new file mode 100644 index 0000000000000..7d0f2673ae142 --- /dev/null +++ b/docs/about/contributing/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +Coder welcomes feedback from security researchers and the general public to help improve our security. +If you believe you have discovered a vulnerability, privacy issue, exposed data, or other security issues +in any of our assets, we want to hear from you. + +If you find a vulnerability, **DO NOT FILE AN ISSUE**. +Instead, send an email to +. + +Refer to the [Security policy](https://coder.com/security/policy) for more information. diff --git a/docs/contributing/backend.md b/docs/about/contributing/backend.md similarity index 100% rename from docs/contributing/backend.md rename to docs/about/contributing/backend.md diff --git a/docs/contributing/documentation.md b/docs/about/contributing/documentation.md similarity index 100% rename from docs/contributing/documentation.md rename to docs/about/contributing/documentation.md diff --git a/docs/contributing/frontend.md b/docs/about/contributing/frontend.md similarity index 99% rename from docs/contributing/frontend.md rename to docs/about/contributing/frontend.md index 62e86c9ad4ab9..b121b01a26c59 100644 --- a/docs/contributing/frontend.md +++ b/docs/about/contributing/frontend.md @@ -250,7 +250,7 @@ new conventions, but all new components should follow these guidelines. ## Styling -We use [Emotion](https://emotion.sh/) to handle css styles. +We use [Emotion](https://emotion.sh/) to handle CSS styles. ## Forms diff --git a/docs/start/screenshots.md b/docs/about/screenshots.md similarity index 100% rename from docs/start/screenshots.md rename to docs/about/screenshots.md diff --git a/docs/start/why-coder.md b/docs/about/why-coder.md similarity index 100% rename from docs/start/why-coder.md rename to docs/about/why-coder.md diff --git a/docs/admin/security/index.md b/docs/admin/security/index.md index 84d89d0c34668..37028093f8c57 100644 --- a/docs/admin/security/index.md +++ b/docs/admin/security/index.md @@ -9,8 +9,7 @@ For other security tips, visit our guide to > [!CAUTION] > If you discover a vulnerability in Coder, please do not hesitate to report it -> to us by following the instructions -> [here](https://github.com/coder/coder/blob/main/SECURITY.md). +> to us by following the [security policy](https://github.com/coder/coder/blob/main/SECURITY.md). From time to time, Coder employees or other community members may discover vulnerabilities in the product. diff --git a/docs/admin/templates/extending-templates/modules.md b/docs/admin/templates/extending-templates/modules.md index 1f454bb26540c..d7ed472831662 100644 --- a/docs/admin/templates/extending-templates/modules.md +++ b/docs/admin/templates/extending-templates/modules.md @@ -54,14 +54,14 @@ For a full list of available modules please check ## Offline installations -In offline and restricted deploymnets, there are 2 ways to fetch modules. +In offline and restricted deployments, there are two ways to fetch modules. 1. Artifactory 2. Private git repository ### Artifactory -Air gapped users can clone the [coder/modules](https://github.com/coder/modules) +Air gapped users can clone the [coder/registry](https://github.com/coder/registry/) repo and publish a [local terraform module repository](https://jfrog.com/help/r/jfrog-artifactory-documentation/set-up-a-terraform-module/provider-registry) to resolve modules via [Artifactory](https://jfrog.com/artifactory/). @@ -71,8 +71,8 @@ to resolve modules via [Artifactory](https://jfrog.com/artifactory/). 3. Follow the below instructions to publish coder modules to Artifactory ```shell - git clone https://github.com/coder/modules - cd modules + git clone https://github.com/coder/registry + cd registry/coder/modules jf tfc jf tf p --namespace="coder" --provider="coder" --tag="1.0.0" ``` diff --git a/docs/ai-coder/custom-agents.md b/docs/ai-coder/custom-agents.md index 451c47689b6b0..3badc20cd8066 100644 --- a/docs/ai-coder/custom-agents.md +++ b/docs/ai-coder/custom-agents.md @@ -40,10 +40,10 @@ any-custom-agent configure-mcp --name "coder" --command "coder exp mcp server" This will start the MCP server and report activity back to the Coder control plane on behalf of the coder_app resource. -> See the [Goose module](https://github.com/coder/modules/blob/main/goose/main.tf) source code for a real world example. +> See the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf) source code for a real world example. ## Contributing We welcome contributions for various agents via the [Coder registry](https://registry.coder.com/modules?tag=agent)! -See our [contributing guide](https://github.com/coder/modules/blob/main/CONTRIBUTING.md) for more information. +See our [contributing guide](https://github.com/coder/registry/blob/main/CONTRIBUTING.md) for more information. diff --git a/docs/contributing/SECURITY.md b/docs/contributing/SECURITY.md deleted file mode 100644 index 7344f126449fe..0000000000000 --- a/docs/contributing/SECURITY.md +++ /dev/null @@ -1,4 +0,0 @@ -# Security Policy - -If you find a vulnerability, **DO NOT FILE AN ISSUE**. Instead, send an email to -. diff --git a/docs/manifest.json b/docs/manifest.json index 0133eb31c1c9a..e100a561aa40c 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -7,15 +7,65 @@ "path": "./README.md", "icon_path": "./images/icons/home.svg", "children": [ + { + "title": "Screenshots", + "description": "View screenshots of the Coder platform", + "path": "./about/screenshots.md" + }, { "title": "Quickstart", "description": "Learn how to install and run Coder quickly", "path": "./tutorials/quickstart.md" }, { - "title": "Screenshots", - "description": "View screenshots of the Coder platform", - "path": "./start/screenshots.md" + "title": "Support", + "description": "How Coder supports your deployment and you", + "path": "./support/index.md", + "children": [ + { + "title": "Generate a Support Bundle", + "description": "Generate and upload a Support Bundle to Coder Support", + "path": "./support/support-bundle.md" + } + ] + }, + { + "title": "Contributing", + "description": "Learn how to contribute to Coder", + "path": "./about/contributing/CONTRIBUTING.md", + "icon_path": "./images/icons/contributing.svg", + "children": [ + { + "title": "Code of Conduct", + "description": "See the code of conduct for contributing to Coder", + "path": "./about/contributing/CODE_OF_CONDUCT.md", + "icon_path": "./images/icons/circle-dot.svg" + }, + { + "title": "Documentation", + "description": "Our style guide for use when authoring documentation", + "path": "./about/contributing/documentation.md", + "icon_path": "./images/icons/document.svg" + }, + { + "title": "Backend", + "description": "Our guide for backend development", + "path": "./about/contributing/backend.md", + "icon_path": "./images/icons/gear.svg" + }, + { + "title": "Frontend", + "description": "Our guide for frontend development", + "path": "./about/contributing/frontend.md", + "icon_path": "./images/icons/frontend.svg" + }, + { + "title": "Security", + "description": "Security vulnerability disclosure policy", + "path": "./about/contributing/SECURITY.md", + "icon_path": "./images/icons/lock.svg" + } + ] } ] }, @@ -810,44 +860,6 @@ } ] }, - { - "title": "Contributing", - "description": "Learn how to contribute to Coder", - "path": "./CONTRIBUTING.md", - "icon_path": "./images/icons/contributing.svg", - "children": [ - { - "title": "Code of Conduct", - "description": "See the code of conduct for contributing to Coder", - "path": "./contributing/CODE_OF_CONDUCT.md", - "icon_path": "./images/icons/circle-dot.svg" - }, - { - "title": "Documentation", - "description": "Our style guide for use when authoring documentation", - "path": "./contributing/documentation.md", - "icon_path": "./images/icons/document.svg" - }, - { - "title": "Backend", - "description": "Our guide for backend development", - "path": "./contributing/backend.md", - "icon_path": "./images/icons/gear.svg" - }, - { - "title": "Frontend", - "description": "Our guide for frontend development", - "path": "./contributing/frontend.md", - "icon_path": "./images/icons/frontend.svg" - }, - { - "title": "Security", - "description": "Our guide for security", - "path": "./contributing/SECURITY.md", - "icon_path": "./images/icons/lock.svg" - } - ] - }, { "title": "Tutorials", "description": "Coder knowledgebase for administrating your deployment", @@ -874,11 +886,6 @@ "description": "Learn about image management with Coder", "path": "./admin/templates/managing-templates/image-management.md" }, - { - "title": "Generate a Support Bundle", - "description": "Generate and upload a Support Bundle to Coder Support", - "path": "./tutorials/support-bundle.md" - }, { "title": "Configuring Okta", "description": "Custom claims/scopes with Okta for group/role sync", diff --git a/docs/support/index.md b/docs/support/index.md new file mode 100644 index 0000000000000..28787b364f3e1 --- /dev/null +++ b/docs/support/index.md @@ -0,0 +1,5 @@ +# Support + +If you have questions, encounter an issue or bug, or if you have a feature request, [open a GitHub issue](https://github.com/coder/coder/issues/new) or [join our Discord](https://discord.gg/coder). + + diff --git a/docs/tutorials/support-bundle.md b/docs/support/support-bundle.md similarity index 100% rename from docs/tutorials/support-bundle.md rename to docs/support/support-bundle.md diff --git a/docs/user-guides/workspace-access/index.md b/docs/user-guides/workspace-access/index.md index 76c1c77120487..1bf4d9d8c9927 100644 --- a/docs/user-guides/workspace-access/index.md +++ b/docs/user-guides/workspace-access/index.md @@ -140,7 +140,7 @@ Supported IDEs: Our [Module Registry](https://registry.coder.com/modules) also hosts a variety of tools for extending the capability of your workspace. If you have a request for a new IDE or tool, please file an issue in our -[Modules repo](https://github.com/coder/modules/issues). +[Modules repo](https://github.com/coder/registry/issues). ## Ports and Port forwarding From bc74166963affa8093140e6dbe06d2c2f6937c66 Mon Sep 17 00:00:00 2001 From: Asher Date: Thu, 12 Jun 2025 12:35:43 -0800 Subject: [PATCH 2/3] feat: check for external auth before running task (#18339) It seems we do not validate external auth in the backend currently, so I opted to do this in the frontend to match the create workspace page. This adds a new section underneath the task prompt for external auth that only shows when there is non-optional missing auth. Closes #18166 --- site/src/hooks/useExternalAuth.ts | 54 +++++++++ .../CreateWorkspacePage.tsx | 48 +------- .../CreateWorkspacePageView.tsx | 6 +- .../CreateWorkspacePageViewExperimental.tsx | 6 +- .../src/pages/TasksPage/TasksPage.stories.tsx | 108 +++++++++++++++++- site/src/pages/TasksPage/TasksPage.tsx | 73 ++++++++++-- .../ExternalAuthPage/ExternalAuthPageView.tsx | 2 +- 7 files changed, 230 insertions(+), 67 deletions(-) create mode 100644 site/src/hooks/useExternalAuth.ts diff --git a/site/src/hooks/useExternalAuth.ts b/site/src/hooks/useExternalAuth.ts new file mode 100644 index 0000000000000..942ce25fa892e --- /dev/null +++ b/site/src/hooks/useExternalAuth.ts @@ -0,0 +1,54 @@ +import { templateVersionExternalAuth } from "api/queries/templates"; +import { useCallback, useEffect, useState } from "react"; +import { useQuery } from "react-query"; + +export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; + +export const useExternalAuth = (versionId: string | undefined) => { + const [externalAuthPollingState, setExternalAuthPollingState] = + useState("idle"); + + const startPollingExternalAuth = useCallback(() => { + setExternalAuthPollingState("polling"); + }, []); + + const { + data: externalAuth, + isPending: isLoadingExternalAuth, + error, + } = useQuery({ + ...templateVersionExternalAuth(versionId ?? ""), + enabled: !!versionId, + refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, + }); + + const allSignedIn = externalAuth?.every((it) => it.authenticated); + + useEffect(() => { + if (allSignedIn) { + setExternalAuthPollingState("idle"); + return; + } + + if (externalAuthPollingState !== "polling") { + return; + } + + // Poll for a maximum of one minute + const quitPolling = setTimeout( + () => setExternalAuthPollingState("abandoned"), + 60_000, + ); + return () => { + clearTimeout(quitPolling); + }; + }, [externalAuthPollingState, allSignedIn]); + + return { + startPollingExternalAuth, + externalAuth, + externalAuthPollingState, + isLoadingExternalAuth, + externalAuthError: error, + }; +}; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx index e5a18edbc2224..243bd3cb9be2d 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePage.tsx @@ -4,7 +4,6 @@ import { checkAuthorization } from "api/queries/authCheck"; import { richParameters, templateByName, - templateVersionExternalAuth, templateVersionPresets, } from "api/queries/templates"; import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces"; @@ -17,6 +16,7 @@ import type { import { Loader } from "components/Loader/Loader"; import { useAuthenticated } from "hooks"; import { useEffectEvent } from "hooks/hookPolyfills"; +import { useExternalAuth } from "hooks/useExternalAuth"; import { useDashboard } from "modules/dashboard/useDashboard"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, useCallback, useEffect, useRef, useState } from "react"; @@ -35,8 +35,6 @@ import { const createWorkspaceModes = ["form", "auto", "duplicate"] as const; export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number]; -export type ExternalAuthPollingState = "idle" | "polling" | "abandoned"; - const CreateWorkspacePage: FC = () => { const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; @@ -237,50 +235,6 @@ const CreateWorkspacePage: FC = () => { ); }; -const useExternalAuth = (versionId: string | undefined) => { - const [externalAuthPollingState, setExternalAuthPollingState] = - useState("idle"); - - const startPollingExternalAuth = useCallback(() => { - setExternalAuthPollingState("polling"); - }, []); - - const { data: externalAuth, isPending: isLoadingExternalAuth } = useQuery({ - ...templateVersionExternalAuth(versionId ?? ""), - enabled: !!versionId, - refetchInterval: externalAuthPollingState === "polling" ? 1000 : false, - }); - - const allSignedIn = externalAuth?.every((it) => it.authenticated); - - useEffect(() => { - if (allSignedIn) { - setExternalAuthPollingState("idle"); - return; - } - - if (externalAuthPollingState !== "polling") { - return; - } - - // Poll for a maximum of one minute - const quitPolling = setTimeout( - () => setExternalAuthPollingState("abandoned"), - 60_000, - ); - return () => { - clearTimeout(quitPolling); - }; - }, [externalAuthPollingState, allSignedIn]); - - return { - startPollingExternalAuth, - externalAuth, - externalAuthPollingState, - isLoadingExternalAuth, - }; -}; - const getAutofillParameters = ( urlSearchParams: URLSearchParams, userParameters: UserParameter[], diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index d365a565afcdb..7a880e8df26b6 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -27,6 +27,7 @@ import { Stack } from "components/Stack/Stack"; import { Switch } from "components/Switch/Switch"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { generateWorkspaceName } from "modules/workspaces/generateWorkspaceName"; import { type FC, useCallback, useEffect, useMemo, useState } from "react"; import { @@ -40,10 +41,7 @@ import { useValidationSchemaForRichParameters, } from "utils/richParameters"; import * as Yup from "yup"; -import type { - CreateWorkspaceMode, - ExternalAuthPollingState, -} from "./CreateWorkspacePage"; +import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 4fff4db92e21d..d0226332227f9 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -26,6 +26,7 @@ import { } from "components/Tooltip/Tooltip"; import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete"; import { type FormikContextType, useFormik } from "formik"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { ArrowLeft, CircleHelp } from "lucide-react"; import { useSyncFormParameters } from "modules/hooks/useSyncFormParameters"; import { Diagnostics } from "modules/workspaces/DynamicParameter/DynamicParameter"; @@ -47,10 +48,7 @@ import { docs } from "utils/docs"; import { nameValidator } from "utils/formUtils"; import type { AutofillBuildParameter } from "utils/richParameters"; import * as Yup from "yup"; -import type { - CreateWorkspaceMode, - ExternalAuthPollingState, -} from "./CreateWorkspacePage"; +import type { CreateWorkspaceMode } from "./CreateWorkspacePage"; import { ExternalAuthButton } from "./ExternalAuthButton"; import type { CreateWorkspacePermissions } from "./permissions"; diff --git a/site/src/pages/TasksPage/TasksPage.stories.tsx b/site/src/pages/TasksPage/TasksPage.stories.tsx index 9b6179ab9bae2..287018cf5a2d7 100644 --- a/site/src/pages/TasksPage/TasksPage.stories.tsx +++ b/site/src/pages/TasksPage/TasksPage.stories.tsx @@ -1,9 +1,11 @@ import type { Meta, StoryObj } from "@storybook/react"; -import { expect, spyOn, userEvent, within } from "@storybook/test"; +import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test"; import { API } from "api/api"; import { MockUsers } from "pages/UsersPage/storybookData/users"; import { MockTemplate, + MockTemplateVersionExternalAuthGithub, + MockTemplateVersionExternalAuthGithubAuthenticated, MockUserOwner, MockWorkspace, MockWorkspaceAppStatus, @@ -27,10 +29,20 @@ const meta: Meta = { }, }, beforeEach: () => { + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([]); spyOn(API, "getUsers").mockResolvedValue({ users: MockUsers, count: MockUsers.length, }); + spyOn(data, "fetchAITemplates").mockResolvedValue([ + MockTemplate, + { + ...MockTemplate, + id: "test-template-2", + name: "template 2", + display_name: "Template 2", + }, + ]); }, }; @@ -134,6 +146,7 @@ export const CreateTaskSuccessfully: Story = { const prompt = await canvas.findByLabelText(/prompt/i); await userEvent.type(prompt, newTaskData.prompt); const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); }); @@ -164,6 +177,7 @@ export const CreateTaskError: Story = { const prompt = await canvas.findByLabelText(/prompt/i); await userEvent.type(prompt, "Create a new task"); const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); await userEvent.click(submitButton); }); @@ -173,6 +187,98 @@ export const CreateTaskError: Story = { }, }; +export const WithExternalAuth: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithubAuthenticated, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Run task", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + }); + + await step("Verify task in the table", async () => { + await canvas.findByRole("row", { + name: new RegExp(newTaskData.prompt, "i"), + }); + }); + + await step("Does not render external auth", async () => { + expect( + canvas.queryByText(/external authentication/), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const MissingExternalAuth: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockResolvedValue([ + MockTemplateVersionExternalAuthGithub, + ]); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Submit is disabled", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + expect(submitButton).toBeDisabled(); + }); + + await step("Renders external authentication", async () => { + await canvas.findByRole("button", { name: /login with github/i }); + }); + }, +}; + +export const ExternalAuthError: Story = { + decorators: [withProxyProvider()], + beforeEach: () => { + spyOn(data, "fetchTasks") + .mockResolvedValueOnce(MockTasks) + .mockResolvedValue([newTaskData, ...MockTasks]); + spyOn(data, "createTask").mockResolvedValue(newTaskData); + spyOn(API, "getTemplateVersionExternalAuth").mockRejectedValue( + mockApiError({ + message: "Failed to load external auth", + }), + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Submit is disabled", async () => { + const prompt = await canvas.findByLabelText(/prompt/i); + await userEvent.type(prompt, newTaskData.prompt); + const submitButton = canvas.getByRole("button", { name: /run task/i }); + expect(submitButton).toBeDisabled(); + }); + + await step("Renders error", async () => { + await canvas.findByText(/failed to load external auth/i); + }); + }, +}; + export const NonAdmin: Story = { decorators: [withProxyProvider()], parameters: { diff --git a/site/src/pages/TasksPage/TasksPage.tsx b/site/src/pages/TasksPage/TasksPage.tsx index adb978cb05cac..02f7f5651092e 100644 --- a/site/src/pages/TasksPage/TasksPage.tsx +++ b/site/src/pages/TasksPage/TasksPage.tsx @@ -2,9 +2,11 @@ import { API } from "api/api"; import { getErrorDetail, getErrorMessage } from "api/errors"; import { disabledRefetchOptions } from "api/queries/util"; import type { Template } from "api/typesGenerated"; +import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; import { Button } from "components/Button/Button"; +import { Form, FormFields, FormSection } from "components/Form/Form"; import { displayError } from "components/GlobalSnackbar/utils"; import { Margins } from "components/Margins/Margins"; import { @@ -28,7 +30,9 @@ import { TableHeader, TableRow, } from "components/Table/Table"; + import { useAuthenticated } from "hooks"; +import { useExternalAuth } from "hooks/useExternalAuth"; import { ExternalLinkIcon, RotateCcwIcon, SendIcon } from "lucide-react"; import { AI_PROMPT_PARAMETER_NAME, type Task } from "modules/tasks/tasks"; import { WorkspaceAppStatus } from "modules/workspaces/WorkspaceAppStatus/WorkspaceAppStatus"; @@ -40,6 +44,7 @@ import { Link as RouterLink } from "react-router-dom"; import TextareaAutosize from "react-textarea-autosize"; import { pageTitle } from "utils/page"; import { relativeTime } from "utils/time"; +import { ExternalAuthButton } from "../CreateWorkspacePage/ExternalAuthButton"; import { type UserOption, UsersCombobox } from "./UsersCombobox"; type TasksFilter = { @@ -161,6 +166,21 @@ const TaskForm: FC = ({ templates }) => { const { user } = useAuthenticated(); const queryClient = useQueryClient(); + const [templateId, setTemplateId] = useState(templates[0].id); + const { + externalAuth, + externalAuthPollingState, + startPollingExternalAuth, + isLoadingExternalAuth, + externalAuthError, + } = useExternalAuth( + templates.find((t) => t.id === templateId)?.active_version_id, + ); + + const hasAllRequiredExternalAuth = externalAuth?.every( + (auth) => auth.optional || auth.authenticated, + ); + const createTaskMutation = useMutation({ mutationFn: async ({ prompt, templateId }: CreateTaskMutationFnProps) => data.createTask(prompt, user.id, templateId), @@ -197,12 +217,13 @@ const TaskForm: FC = ({ templates }) => { }; return ( -
-
+ + {Boolean(externalAuthError) && } + +
@@ -215,7 +236,12 @@ const TaskForm: FC = ({ templates }) => { text-sm shadow-sm text-content-primary placeholder:text-content-secondary md:text-sm`} />
- setTemplateId(value)} + defaultValue={templates[0].id} + required + > @@ -232,15 +258,42 @@ const TaskForm: FC = ({ templates }) => { -
- + + {!hasAllRequiredExternalAuth && + externalAuth && + externalAuth.length > 0 && ( + + + {externalAuth.map((auth) => ( + + ))} + + + )} + ); }; diff --git a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx index c81dd45c61cd5..b4924a5a09381 100644 --- a/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx +++ b/site/src/pages/UserSettingsPage/ExternalAuthPage/ExternalAuthPageView.tsx @@ -27,8 +27,8 @@ import { Loader } from "components/Loader/Loader"; import { Spinner } from "components/Spinner/Spinner"; import { Stack } from "components/Stack/Stack"; import { TableEmpty } from "components/TableEmpty/TableEmpty"; +import type { ExternalAuthPollingState } from "hooks/useExternalAuth"; import { EllipsisVertical } from "lucide-react"; -import type { ExternalAuthPollingState } from "pages/CreateWorkspacePage/CreateWorkspacePage"; import { type FC, useCallback, useEffect, useState } from "react"; import { useQuery } from "react-query"; From dd150264bc4f57cb075ebd6f1086115a8b5e781e Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Thu, 12 Jun 2025 23:36:23 +0100 Subject: [PATCH 3/3] feat(agent/agentcontainers): support displayApps from devcontainer config (#18342) Updates the agent injection routine to read the dev container's configuration so we can add display apps to the sub agent. --- agent/agentcontainers/acmock/acmock.go | 20 +++ agent/agentcontainers/api.go | 12 ++ agent/agentcontainers/api_test.go | 153 +++++++++++++++++- agent/agentcontainers/devcontainercli.go | 114 ++++++++++++- agent/agentcontainers/devcontainercli_test.go | 86 ++++++++++ agent/agentcontainers/subagent.go | 25 +++ agent/agentcontainers/subagent_test.go | 105 ++++++++++++ .../read-config-error-not-found.log | 2 + .../read-config-with-coder-customization.log | 8 + ...ead-config-without-coder-customization.log | 8 + agent/agenttest/client.go | 47 ++++-- 11 files changed, 558 insertions(+), 22 deletions(-) create mode 100644 agent/agentcontainers/subagent_test.go create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log create mode 100644 agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index f9723e8a15758..990a243a33ddf 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -149,6 +149,26 @@ func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockDevcontainerCLI)(nil).Exec), varargs...) } +// ReadConfig mocks base method. +func (m *MockDevcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ReadConfig", varargs...) + ret0, _ := ret[0].(agentcontainers.DevcontainerConfig) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadConfig indicates an expected call of ReadConfig. +func (mr *MockDevcontainerCLIMockRecorder) ReadConfig(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadConfig", reflect.TypeOf((*MockDevcontainerCLI)(nil).ReadConfig), varargs...) +} + // Up mocks base method. func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { m.ctrl.T.Helper() diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 56c5df6710297..ce252fe2909ab 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -1099,6 +1099,17 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders directory = DevcontainerDefaultContainerWorkspaceFolder } + var displayApps []codersdk.DisplayApp + + if config, err := api.dccli.ReadConfig(ctx, dc.WorkspaceFolder, dc.ConfigPath); err != nil { + api.logger.Error(ctx, "unable to read devcontainer config", slog.Error(err)) + } else { + coderCustomization := config.MergedConfiguration.Customizations.Coder + if coderCustomization != nil { + displayApps = coderCustomization.DisplayApps + } + } + // The preparation of the subagent is done, now we can create the // subagent record in the database to receive the auth token. createdAgent, err := api.subAgentClient.Create(ctx, SubAgent{ @@ -1106,6 +1117,7 @@ func (api *API) injectSubAgentIntoContainerLocked(ctx context.Context, dc coders Directory: directory, OperatingSystem: "linux", // Assuming Linux for dev containers. Architecture: arch, + DisplayApps: displayApps, }) if err != nil { return xerrors.Errorf("create agent: %w", err) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 91cebcf2e5d25..d8e696e151db2 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -60,11 +60,14 @@ func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args . // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { - upID string - upErr error - upErrC chan error // If set, send to return err, close to return upErr. - execErr error - execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + upID string + upErr error + upErrC chan error // If set, send to return err, close to return upErr. + execErr error + execErrC chan func(cmd string, args ...string) error // If set, send fn to return err, nil or close to return execErr. + readConfig agentcontainers.DevcontainerConfig + readConfigErr error + readConfigErrC chan error } func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { @@ -95,6 +98,20 @@ func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, cmd string, return f.execErr } +func (f *fakeDevcontainerCLI) ReadConfig(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIReadConfigOptions) (agentcontainers.DevcontainerConfig, error) { + if f.readConfigErrC != nil { + select { + case <-ctx.Done(): + return agentcontainers.DevcontainerConfig{}, ctx.Err() + case err, ok := <-f.readConfigErrC: + if ok { + return f.readConfig, err + } + } + } + return f.readConfig, f.readConfigErr +} + // fakeWatcher implements the watcher.Watcher interface for testing. // It allows controlling what events are sent and when. type fakeWatcher struct { @@ -1132,10 +1149,12 @@ func TestAPI(t *testing.T) { Containers: []codersdk.WorkspaceAgentContainer{container}, }, } + fDCCLI := &fakeDevcontainerCLI{} logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) api := agentcontainers.NewAPI( logger, + agentcontainers.WithDevcontainerCLI(fDCCLI), agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), @@ -1421,6 +1440,130 @@ func TestAPI(t *testing.T) { assert.Contains(t, fakeSAC.deleted, existingAgentID) assert.Empty(t, fakeSAC.agents) }) + + t.Run("Create", func(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("Dev Container tests are not supported on Windows (this test uses mocks but fails due to Windows paths)") + } + + tests := []struct { + name string + customization *agentcontainers.CoderCustomization + afterCreate func(t *testing.T, subAgent agentcontainers.SubAgent) + }{ + { + name: "WithoutCustomization", + customization: nil, + }, + { + name: "WithDisplayApps", + customization: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppSSH, + codersdk.DisplayAppWebTerminal, + codersdk.DisplayAppVSCodeInsiders, + }, + }, + afterCreate: func(t *testing.T, subAgent agentcontainers.SubAgent) { + require.Len(t, subAgent.DisplayApps, 3) + assert.Equal(t, codersdk.DisplayAppSSH, subAgent.DisplayApps[0]) + assert.Equal(t, codersdk.DisplayAppWebTerminal, subAgent.DisplayApps[1]) + assert.Equal(t, codersdk.DisplayAppVSCodeInsiders, subAgent.DisplayApps[2]) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitMedium) + logger = testutil.Logger(t) + mClock = quartz.NewMock(t) + mCCLI = acmock.NewMockContainerCLI(gomock.NewController(t)) + fSAC = &fakeSubAgentClient{createErrC: make(chan error, 1)} + fDCCLI = &fakeDevcontainerCLI{ + readConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: tt.customization, + }, + }, + }, + execErrC: make(chan func(cmd string, args ...string) error, 1), + } + + testContainer = codersdk.WorkspaceAgentContainer{ + ID: "test-container-id", + FriendlyName: "test-container", + Image: "test-image", + Running: true, + CreatedAt: time.Now(), + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/workspaces", + agentcontainers.DevcontainerConfigFileLabel: "/workspace/.devcontainer/devcontainer.json", + }, + } + ) + + coderBin, err := os.Executable() + require.NoError(t, err) + + // Mock the `List` function to always return out test container. + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{testContainer}, + }, nil).AnyTimes() + + // Mock the steps used for injecting the coder agent. + gomock.InOrder( + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), testContainer.ID).Return(runtime.GOARCH, nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "mkdir", "-p", "/.coder-agent").Return(nil, nil), + mCCLI.EXPECT().Copy(gomock.Any(), testContainer.ID, coderBin, "/.coder-agent/coder").Return(nil), + mCCLI.EXPECT().ExecAs(gomock.Any(), testContainer.ID, "root", "chmod", "0755", "/.coder-agent", "/.coder-agent/coder").Return(nil, nil), + ) + + mClock.Set(time.Now()).MustWait(ctx) + tickerTrap := mClock.Trap().TickerFunc("updaterLoop") + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(fDCCLI), + agentcontainers.WithSubAgentClient(fSAC), + agentcontainers.WithSubAgentURL("test-subagent-url"), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + defer api.Close() + + // Close before api.Close() defer to avoid deadlock after test. + defer close(fSAC.createErrC) + defer close(fDCCLI.execErrC) + + // Given: We allow agent creation and injection to succeed. + testutil.RequireSend(ctx, t, fSAC.createErrC, nil) + testutil.RequireSend(ctx, t, fDCCLI.execErrC, func(cmd string, args ...string) error { + assert.Equal(t, "pwd", cmd) + assert.Empty(t, args) + return nil + }) + + // Wait until the ticker has been registered. + tickerTrap.MustWait(ctx).MustRelease(ctx) + tickerTrap.Close() + + // Then: We expected it to succeed + require.Len(t, fSAC.created, 1) + assert.Equal(t, testContainer.FriendlyName, fSAC.created[0].Name) + + if tt.afterCreate != nil { + tt.afterCreate(t, fSAC.created[0]) + } + }) + } + }) } // mustFindDevcontainerByPath returns the devcontainer with the given workspace diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 4e1ad93a715dc..2fad8c6560067 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -12,12 +12,33 @@ import ( "cdr.dev/slog" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" ) +// DevcontainerConfig is a wrapper around the output from `read-configuration`. +// Unfortunately we cannot make use of `dcspec` as the output doesn't appear to +// match. +type DevcontainerConfig struct { + MergedConfiguration DevcontainerConfiguration `json:"mergedConfiguration"` +} + +type DevcontainerConfiguration struct { + Customizations DevcontainerCustomizations `json:"customizations,omitempty"` +} + +type DevcontainerCustomizations struct { + Coder *CoderCustomization `json:"coder,omitempty"` +} + +type CoderCustomization struct { + DisplayApps []codersdk.DisplayApp `json:"displayApps,omitempty"` +} + // DevcontainerCLI is an interface for the devcontainer CLI. type DevcontainerCLI interface { Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error + ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) } // DevcontainerCLIUpOptions are options for the devcontainer CLI Up @@ -83,6 +104,24 @@ func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { } } +// DevcontainerCLIExecOptions are options for the devcontainer CLI ReadConfig +// command. +type DevcontainerCLIReadConfigOptions func(*devcontainerCLIReadConfigConfig) + +type devcontainerCLIReadConfigConfig struct { + stdout io.Writer + stderr io.Writer +} + +// WithExecOutput sets additional stdout and stderr writers for logs +// during Exec operations. +func WithReadConfigOutput(stdout, stderr io.Writer) DevcontainerCLIReadConfigOptions { + return func(o *devcontainerCLIReadConfigConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { conf := devcontainerCLIUpConfig{} for _, opt := range opts { @@ -103,6 +142,16 @@ func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devconta return conf } +func applyDevcontainerCLIReadConfigOptions(opts []DevcontainerCLIReadConfigOptions) devcontainerCLIReadConfigConfig { + conf := devcontainerCLIReadConfigConfig{} + for _, opt := range opts { + if opt != nil { + opt(&conf) + } + } + return conf +} + type devcontainerCLI struct { logger slog.Logger execer agentexec.Execer @@ -147,13 +196,14 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st cmd.Stderr = io.MultiWriter(stderrWriters...) if err := cmd.Run(); err != nil { - if _, err2 := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()); err2 != nil { + _, err2 := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) + if err2 != nil { err = errors.Join(err, err2) } return "", err } - result, err := parseDevcontainerCLILastLine(ctx, logger, stdoutBuf.Bytes()) + result, err := parseDevcontainerCLILastLine[devcontainerCLIResult](ctx, logger, stdoutBuf.Bytes()) if err != nil { return "", err } @@ -200,9 +250,49 @@ func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath return nil } +func (d *devcontainerCLI) ReadConfig(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIReadConfigOptions) (DevcontainerConfig, error) { + conf := applyDevcontainerCLIReadConfigOptions(opts) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) + + args := []string{"read-configuration", "--include-merged-configuration"} + if workspaceFolder != "" { + args = append(args, "--workspace-folder", workspaceFolder) + } + if configPath != "" { + args = append(args, "--config", configPath) + } + + c := d.execer.CommandContext(ctx, "devcontainer", args...) + + var stdoutBuf bytes.Buffer + stdoutWriters := []io.Writer{&stdoutBuf, &devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} + if conf.stdout != nil { + stdoutWriters = append(stdoutWriters, conf.stdout) + } + c.Stdout = io.MultiWriter(stdoutWriters...) + stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} + if conf.stderr != nil { + stderrWriters = append(stderrWriters, conf.stderr) + } + c.Stderr = io.MultiWriter(stderrWriters...) + + if err := c.Run(); err != nil { + return DevcontainerConfig{}, xerrors.Errorf("devcontainer read-configuration failed: %w", err) + } + + config, err := parseDevcontainerCLILastLine[DevcontainerConfig](ctx, logger, stdoutBuf.Bytes()) + if err != nil { + return DevcontainerConfig{}, err + } + + return config, nil +} + // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. -func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) { +func parseDevcontainerCLILastLine[T any](ctx context.Context, logger slog.Logger, p []byte) (T, error) { + var result T + s := bufio.NewScanner(bytes.NewReader(p)) var lastLine []byte for s.Scan() { @@ -212,19 +302,19 @@ func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []b } lastLine = b } - if err = s.Err(); err != nil { + if err := s.Err(); err != nil { return result, err } if len(lastLine) == 0 || lastLine[0] != '{' { logger.Error(ctx, "devcontainer result is not json", slog.F("result", string(lastLine))) return result, xerrors.Errorf("devcontainer result is not json: %q", string(lastLine)) } - if err = json.Unmarshal(lastLine, &result); err != nil { + if err := json.Unmarshal(lastLine, &result); err != nil { logger.Error(ctx, "parse devcontainer result failed", slog.Error(err), slog.F("result", string(lastLine))) return result, err } - return result, result.Err() + return result, nil } // devcontainerCLIResult is the result of the devcontainer CLI command. @@ -243,6 +333,18 @@ type devcontainerCLIResult struct { Description string `json:"description"` } +func (r *devcontainerCLIResult) UnmarshalJSON(data []byte) error { + type wrapperResult devcontainerCLIResult + + var wrappedResult wrapperResult + if err := json.Unmarshal(data, &wrappedResult); err != nil { + return err + } + + *r = devcontainerCLIResult(wrappedResult) + return r.Err() +} + func (r devcontainerCLIResult) Err() error { if r.Outcome == "success" { return nil diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index b8b4120d2e8ab..dfe390ff7e6df 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -22,6 +22,7 @@ import ( "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" ) @@ -233,6 +234,91 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } }) + + t.Run("ReadConfig", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + logFile string + workspaceFolder string + configPath string + opts []agentcontainers.DevcontainerCLIReadConfigOptions + wantArgs string + wantError bool + wantConfig agentcontainers.DevcontainerConfig + }{ + { + name: "WithCoderCustomization", + logFile: "read-config-with-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: &agentcontainers.CoderCustomization{ + DisplayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppWebTerminal, + }, + }, + }, + }, + }, + }, + { + name: "WithoutCoderCustomization", + logFile: "read-config-without-coder-customization.log", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /test/workspace --config /test/config.json", + wantError: false, + wantConfig: agentcontainers.DevcontainerConfig{ + MergedConfiguration: agentcontainers.DevcontainerConfiguration{ + Customizations: agentcontainers.DevcontainerCustomizations{ + Coder: nil, + }, + }, + }, + }, + { + name: "FileNotFound", + logFile: "read-config-error-not-found.log", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + wantArgs: "read-configuration --include-merged-configuration --workspace-folder /nonexistent/workspace", + wantError: true, + wantConfig: agentcontainers.DevcontainerConfig{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: tt.wantArgs, + wantError: tt.wantError, + logFile: filepath.Join("testdata", "devcontainercli", "readconfig", tt.logFile), + } + + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + config, err := dccli.ReadConfig(ctx, tt.workspaceFolder, tt.configPath, tt.opts...) + if tt.wantError { + assert.Error(t, err, "want error") + assert.Equal(t, agentcontainers.DevcontainerConfig{}, config, "expected empty config on error") + } else { + assert.NoError(t, err, "want no error") + assert.Equal(t, tt.wantConfig, config, "expected config to match") + } + }) + } + }) } // TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI diff --git a/agent/agentcontainers/subagent.go b/agent/agentcontainers/subagent.go index 70899fb96f70d..5848e5747e099 100644 --- a/agent/agentcontainers/subagent.go +++ b/agent/agentcontainers/subagent.go @@ -9,6 +9,7 @@ import ( "cdr.dev/slog" agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" ) // SubAgent represents an agent running in a dev container. @@ -19,6 +20,7 @@ type SubAgent struct { Directory string Architecture string OperatingSystem string + DisplayApps []codersdk.DisplayApp } // SubAgentClient is an interface for managing sub agents and allows @@ -80,11 +82,34 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) { func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (SubAgent, error) { a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory)) + + displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps)) + for _, displayApp := range agent.DisplayApps { + var app agentproto.CreateSubAgentRequest_DisplayApp + switch displayApp { + case codersdk.DisplayAppPortForward: + app = agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER + case codersdk.DisplayAppSSH: + app = agentproto.CreateSubAgentRequest_SSH_HELPER + case codersdk.DisplayAppVSCodeDesktop: + app = agentproto.CreateSubAgentRequest_VSCODE + case codersdk.DisplayAppVSCodeInsiders: + app = agentproto.CreateSubAgentRequest_VSCODE_INSIDERS + case codersdk.DisplayAppWebTerminal: + app = agentproto.CreateSubAgentRequest_WEB_TERMINAL + default: + return SubAgent{}, xerrors.Errorf("unexpected codersdk.DisplayApp: %#v", displayApp) + } + + displayApps = append(displayApps, app) + } + resp, err := a.api.CreateSubAgent(ctx, &agentproto.CreateSubAgentRequest{ Name: agent.Name, Directory: agent.Directory, Architecture: agent.Architecture, OperatingSystem: agent.OperatingSystem, + DisplayApps: displayApps, }) if err != nil { return SubAgent{}, err diff --git a/agent/agentcontainers/subagent_test.go b/agent/agentcontainers/subagent_test.go new file mode 100644 index 0000000000000..4b805d7549fce --- /dev/null +++ b/agent/agentcontainers/subagent_test.go @@ -0,0 +1,105 @@ +package agentcontainers_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agenttest" + agentproto "github.com/coder/coder/v2/agent/proto" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/codersdk/agentsdk" + "github.com/coder/coder/v2/tailnet" + "github.com/coder/coder/v2/testutil" +) + +func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) { + t.Parallel() + + t.Run("CreateWithDisplayApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayApps []codersdk.DisplayApp + expectedApps []agentproto.CreateSubAgentRequest_DisplayApp + }{ + { + name: "single display app", + displayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop}, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + }, + }, + { + name: "multiple display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppSSH, + codersdk.DisplayAppPortForward, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + }, + { + name: "all display apps", + displayApps: []codersdk.DisplayApp{ + codersdk.DisplayAppPortForward, + codersdk.DisplayAppSSH, + codersdk.DisplayAppVSCodeDesktop, + codersdk.DisplayAppVSCodeInsiders, + codersdk.DisplayAppWebTerminal, + }, + expectedApps: []agentproto.CreateSubAgentRequest_DisplayApp{ + agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + agentproto.CreateSubAgentRequest_SSH_HELPER, + agentproto.CreateSubAgentRequest_VSCODE, + agentproto.CreateSubAgentRequest_VSCODE_INSIDERS, + agentproto.CreateSubAgentRequest_WEB_TERMINAL, + }, + }, + { + name: "no display apps", + displayApps: []codersdk.DisplayApp{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + logger := testutil.Logger(t) + statsCh := make(chan *agentproto.Stats) + + agentAPI := agenttest.NewClient(t, logger, uuid.New(), agentsdk.Manifest{}, statsCh, tailnet.NewCoordinator(logger)) + + agentClient, _, err := agentAPI.ConnectRPC26(ctx) + require.NoError(t, err) + + subAgentClient := agentcontainers.NewSubAgentClientFromAPI(logger, agentClient) + + // When: We create a sub agent with display apps. + subAgent, err := subAgentClient.Create(ctx, agentcontainers.SubAgent{ + Name: "sub-agent-" + tt.name, + Directory: "/workspaces/coder", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: tt.displayApps, + }) + require.NoError(t, err) + + displayApps, err := agentAPI.GetSubAgentDisplayApps(subAgent.ID) + require.NoError(t, err) + + // Then: We expect the apps to be created. + require.Equal(t, tt.expectedApps, displayApps) + }) + } + }) +} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log new file mode 100644 index 0000000000000..45d66957a3ba1 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-error-not-found.log @@ -0,0 +1,2 @@ +{"type":"text","level":3,"timestamp":1749557935646,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"text","level":2,"timestamp":1749557935646,"text":"Error: Dev container config (/home/coder/.devcontainer/devcontainer.json) not found.\n at v7 (/usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:668:6918)\n at async /usr/local/nvm/versions/node/v20.16.0/lib/node_modules/@devcontainers/cli/dist/spec-node/devContainersSpecCLI.js:484:1188"} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log new file mode 100644 index 0000000000000..fd052c50662e9 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-with-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"mergedConfiguration":{"customizations":{"coder":{"displayApps":["vscode", "web_terminal"]}}}} diff --git a/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log new file mode 100644 index 0000000000000..98fc180cdd642 --- /dev/null +++ b/agent/agentcontainers/testdata/devcontainercli/readconfig/read-config-without-coder-customization.log @@ -0,0 +1,8 @@ +{"type":"text","level":3,"timestamp":1749557820014,"text":"@devcontainers/cli 0.75.0. Node.js v20.16.0. linux 6.8.0-60-generic x64."} +{"type":"start","level":2,"timestamp":1749557820014,"text":"Run: git rev-parse --show-cdup"} +{"type":"stop","level":2,"timestamp":1749557820023,"text":"Run: git rev-parse --show-cdup","startTimestamp":1749557820014} +{"type":"start","level":2,"timestamp":1749557820023,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json"} +{"type":"stop","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder --filter label=devcontainer.config_file=/home/coder/coder/.devcontainer/devcontainer.json","startTimestamp":1749557820023} +{"type":"start","level":2,"timestamp":1749557820039,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder"} +{"type":"stop","level":2,"timestamp":1749557820054,"text":"Run: docker ps -q -a --filter label=devcontainer.local_folder=/home/coder/coder","startTimestamp":1749557820039} +{"mergedConfiguration":{"customizations":{}}} diff --git a/agent/agenttest/client.go b/agent/agenttest/client.go index 0a2df141ff3d4..0fc8a38af80b6 100644 --- a/agent/agenttest/client.go +++ b/agent/agenttest/client.go @@ -171,22 +171,27 @@ func (c *Client) GetSubAgentDirectory(id uuid.UUID) (string, error) { return c.fakeAgentAPI.GetSubAgentDirectory(id) } +func (c *Client) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + return c.fakeAgentAPI.GetSubAgentDisplayApps(id) +} + type FakeAgentAPI struct { sync.Mutex t testing.TB logger slog.Logger - manifest *agentproto.Manifest - startupCh chan *agentproto.Startup - statsCh chan *agentproto.Stats - appHealthCh chan *agentproto.BatchUpdateAppHealthRequest - logsCh chan<- *agentproto.BatchCreateLogsRequest - lifecycleStates []codersdk.WorkspaceAgentLifecycle - metadata map[string]agentsdk.Metadata - timings []*agentproto.Timing - connectionReports []*agentproto.ReportConnectionRequest - subAgents map[uuid.UUID]*agentproto.SubAgent - subAgentDirs map[uuid.UUID]string + manifest *agentproto.Manifest + startupCh chan *agentproto.Startup + statsCh chan *agentproto.Stats + appHealthCh chan *agentproto.BatchUpdateAppHealthRequest + logsCh chan<- *agentproto.BatchCreateLogsRequest + lifecycleStates []codersdk.WorkspaceAgentLifecycle + metadata map[string]agentsdk.Metadata + timings []*agentproto.Timing + connectionReports []*agentproto.ReportConnectionRequest + subAgents map[uuid.UUID]*agentproto.SubAgent + subAgentDirs map[uuid.UUID]string + subAgentDisplayApps map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error) getResourcesMonitoringConfigurationFunc func() (*agentproto.GetResourcesMonitoringConfigurationResponse, error) @@ -401,6 +406,10 @@ func (f *FakeAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Creat f.subAgentDirs = make(map[uuid.UUID]string) } f.subAgentDirs[subAgentID] = req.GetDirectory() + if f.subAgentDisplayApps == nil { + f.subAgentDisplayApps = make(map[uuid.UUID][]agentproto.CreateSubAgentRequest_DisplayApp) + } + f.subAgentDisplayApps[subAgentID] = req.GetDisplayApps() // For a fake implementation, we don't create workspace apps. // Real implementations would handle req.Apps here. @@ -477,6 +486,22 @@ func (f *FakeAgentAPI) GetSubAgentDirectory(id uuid.UUID) (string, error) { return dir, nil } +func (f *FakeAgentAPI) GetSubAgentDisplayApps(id uuid.UUID) ([]agentproto.CreateSubAgentRequest_DisplayApp, error) { + f.Lock() + defer f.Unlock() + + if f.subAgentDisplayApps == nil { + return nil, xerrors.New("no sub-agent display apps available") + } + + displayApps, ok := f.subAgentDisplayApps[id] + if !ok { + return nil, xerrors.New("sub-agent display apps not found") + } + + return displayApps, nil +} + func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI { return &FakeAgentAPI{ t: t,