diff --git a/.icons/open-collaboration-tools.svg b/.icons/open-collaboration-tools.svg new file mode 100644 index 000000000..34753b389 --- /dev/null +++ b/.icons/open-collaboration-tools.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/registry/edd88-pixel/.images/open-collaboration-tools-e2e.png b/registry/edd88-pixel/.images/open-collaboration-tools-e2e.png new file mode 100644 index 000000000..d3dbc10f7 Binary files /dev/null and b/registry/edd88-pixel/.images/open-collaboration-tools-e2e.png differ diff --git a/registry/edd88-pixel/.images/open-collaboration-tools-workspace-running.png b/registry/edd88-pixel/.images/open-collaboration-tools-workspace-running.png new file mode 100644 index 000000000..453e1d0e8 Binary files /dev/null and b/registry/edd88-pixel/.images/open-collaboration-tools-workspace-running.png differ diff --git a/registry/edd88-pixel/modules/open-collaboration-tools/README.md b/registry/edd88-pixel/modules/open-collaboration-tools/README.md new file mode 100644 index 000000000..66cede5ad --- /dev/null +++ b/registry/edd88-pixel/modules/open-collaboration-tools/README.md @@ -0,0 +1,111 @@ +--- +display_name: Open Collaboration Tools +description: Configure live collaborative editing with Open Collaboration Tools in Coder web IDEs +icon: ../../../../.icons/open-collaboration-tools.svg +verified: false +tags: [collaboration, ide, pair-programming, vscode] +--- + +# Open Collaboration Tools + +Configure the official Open Collaboration Tools (OCT) extension for live collaborative editing in a Coder web IDE. The module supplies settings and a versioned extension identifier that compose with the existing code-server or VS Code Web modules. + +```tf +module "open_collaboration_tools" { + source = "registry.coder.com/edd88-pixel/open-collaboration-tools/coder" + version = "1.0.0" + + server_url = "https://oct.example.com" +} + +module "code_server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "1.4.1" + + agent_id = coder_agent.main.id + extensions = module.open_collaboration_tools.extensions + settings = module.open_collaboration_tools.settings +} +``` + +![Two Coder workspaces editing the same file through Open Collaboration Tools](../../.images/open-collaboration-tools-e2e.png) + +![Coder workspace list showing the OCT participant workspace running](../../.images/open-collaboration-tools-workspace-running.png) + +## How Coder and OCT fit together + +Coder creates and secures the workspaces and web IDE entry points. A long-lived OCT server brokers collaboration sessions between the OCT extensions running in those IDEs. This module configures only the workspace-facing extension; it does not deploy the shared server or place OAuth credentials in a workspace or Terraform state. + +OCT sessions are held in memory by the server. Do not run one server per workspace, and do not rely on a room surviving a server restart. + +## VS Code Web + +The same outputs compose with the VS Code Web module: + +```tf +module "vscode_web" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/vscode-web/coder" + version = "1.1.0" + + agent_id = coder_agent.main.id + accept_license = true + extensions = module.open_collaboration_tools.extensions + settings = module.open_collaboration_tools.settings +} +``` + +When an IDE already contains the OCT extension, set `install_extension = false`. The module then returns an empty extension list while continuing to manage the OCT settings. + +## Joining policy + +The default `prompt` policy requires the host to approve every participant. An `allowlist` can admit selected usernames without prompting: + +```tf +module "open_collaboration_tools" { + source = "registry.coder.com/edd88-pixel/open-collaboration-tools/coder" + version = "1.0.0" + + server_url = "https://oct.example.com" + join_accept_mode = "allowlist" + join_allowlist = ["alice", "bob"] +} +``` + +Set `join_accept_mode = "auto"` only for a trusted environment where every authenticated OCT user may enter a hosted session without confirmation. + +## Coder OAuth2 administrator setup + +Coder's OAuth2 provider is experimental and should be enabled only after reviewing its current limitations. An administrator must enable the `oauth2` experiment, create an OAuth2 application, and register the exact OCT callback URL: + +```text +https://oct.example.com/api/login/oauth-callback +``` + +Configure the external OCT service with its base URL, Coder's `/oauth2/authorize` and `/oauth2/tokens` endpoints, Coder's `/api/v2/users/me` user-info endpoint, the `username` and `email` claims, and S256 PKCE. Inject `OCT_OAUTH_CLIENTSECRET` from the deployment's secret manager; never place it in a Coder template or workspace environment. + +## Create and join a session + +In the host IDE, run **Open Collaboration Tools: Create Collaboration Session**. After authentication, share the invitation code through a trusted channel. In the participant IDE, run **Open Collaboration Tools: Join Collaboration Session** and enter that invitation. + +The published extension also exposes `oct.createRoom` and `oct.joinRoom` to other VS Code extensions. External desktop launchers can use the OCT `vscode://` join URI, but browser-hosted IDEs should use the commands inside the extension. + +## Network and restricted environments + +This module runs no scripts, requires no elevated privileges, and downloads nothing itself. With extension installation enabled, the selected IDE module contacts its configured extension marketplace to obtain `typefox.open-collaboration-tools`; at runtime, the extension contacts the configured OCT server, which redirects authentication to the Coder deployment. + +For restricted environments, mirror or preinstall the extension through the IDE module or workspace image, set `install_extension = false`, and allow only the Coder and OCT origins required by the deployment. The OCT server and the browser must both be able to reach the Coder OAuth2 endpoints. No public OCT service is required. + +## Server health check + +The OCT server does not provide a home page. Opening its root URL can therefore return `Cannot GET /` even when the service is healthy. Use the metadata endpoint for a non-authenticated connectivity check: + +```shell +curl --fail --show-error https://oct.example.com/api/meta +``` + +During an actual session, the extension also uses `/api/login/*` for authentication and `/api/session/*` for collaboration. Those routes require the appropriate request method and session context, so `/api/meta` is the clearer standalone health probe. + +> [!WARNING] +> OCT does not share terminals or forwarded ports. A session also ends when its in-memory OCT server state is lost. diff --git a/registry/edd88-pixel/modules/open-collaboration-tools/main.test.ts b/registry/edd88-pixel/modules/open-collaboration-tools/main.test.ts new file mode 100644 index 000000000..ef5d306ed --- /dev/null +++ b/registry/edd88-pixel/modules/open-collaboration-tools/main.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +describe("open-collaboration-tools", async () => { + await runTerraformInit(import.meta.dir); + + testRequiredVariables(import.meta.dir, { + server_url: "https://oct.example.com", + }); + + it("exposes defaults for web IDE composition", async () => { + const state = await runTerraformApply(import.meta.dir, { + server_url: "https://oct.example.com", + }); + + expect(state.outputs.extensions.value).toEqual([ + "typefox.open-collaboration-tools@0.3.9", + ]); + expect(state.outputs.settings.value).toEqual({ + "oct.alwaysAskToOverrideServerUrl": false, + "oct.files.exclude": ["**/.env"], + "oct.joinAcceptMode": "prompt", + "oct.joinAllowlist": [], + "oct.serverUrl": "https://oct.example.com/", + }); + }); + + it("supports an extension already installed in the image", async () => { + const state = await runTerraformApply(import.meta.dir, { + server_url: "https://oct.example.com", + install_extension: false, + }); + + expect(state.outputs.extensions.value).toEqual([]); + }); + + it("preserves a configured collaboration policy", async () => { + const state = await runTerraformApply(import.meta.dir, { + server_url: "http://localhost:8100/api", + extension_id: "internal.open-collaboration-tools", + extension_version: "0.3.9-internal.1", + always_ask_to_override_server_url: true, + join_accept_mode: "allowlist", + join_allowlist: '["alice","bob"]', + excluded_files: '["**/.env","**/*.pem"]', + }); + + expect(state.outputs.extensions.value).toEqual([ + "internal.open-collaboration-tools@0.3.9-internal.1", + ]); + expect(state.outputs.settings.value).toEqual({ + "oct.alwaysAskToOverrideServerUrl": true, + "oct.files.exclude": ["**/.env", "**/*.pem"], + "oct.joinAcceptMode": "allowlist", + "oct.joinAllowlist": ["alice", "bob"], + "oct.serverUrl": "http://localhost:8100/api/", + }); + }); +}); diff --git a/registry/edd88-pixel/modules/open-collaboration-tools/main.tf b/registry/edd88-pixel/modules/open-collaboration-tools/main.tf new file mode 100644 index 000000000..5527cdc07 --- /dev/null +++ b/registry/edd88-pixel/modules/open-collaboration-tools/main.tf @@ -0,0 +1,104 @@ +terraform { + required_version = ">= 1.0" +} + +variable "server_url" { + description = "URL of the Open Collaboration Tools server. HTTPS is required except for localhost development servers." + type = string + + validation { + condition = ( + can(regex("^https://[^/[:space:]]+(:[0-9]{1,5})?(/[^[:space:]]*)?$", var.server_url)) || + can(regex("^http://(localhost|127\\.0\\.0\\.1)(:[0-9]{1,5})?(/[^[:space:]]*)?$", var.server_url)) + ) + error_message = "server_url must use HTTPS, except that HTTP is allowed for localhost or 127.0.0.1." + } +} + +variable "extension_id" { + description = "Identifier of the Open Collaboration Tools extension to configure." + type = string + default = "typefox.open-collaboration-tools" + + validation { + condition = can(regex("^[A-Za-z0-9][A-Za-z0-9-]*\\.[A-Za-z0-9][A-Za-z0-9-]*$", var.extension_id)) + error_message = "extension_id must use the publisher.extension format." + } +} + +variable "extension_version" { + description = "Version of the Open Collaboration Tools extension to install." + type = string + default = "0.3.9" + + validation { + condition = can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+([+-][0-9A-Za-z.-]+)?$", var.extension_version)) + error_message = "extension_version must be a semantic version such as 0.3.9." + } +} + +variable "install_extension" { + description = "Whether compatible IDE modules should install the configured extension. Disable this when the extension is already present in the workspace image." + type = bool + default = true +} + +variable "always_ask_to_override_server_url" { + description = "Whether OCT should ask before switching to the server URL contained in an invitation." + type = bool + default = false +} + +variable "join_accept_mode" { + description = "Policy used by a host when another user requests to join: prompt, allowlist, or auto." + type = string + default = "prompt" + + validation { + condition = contains(["prompt", "allowlist", "auto"], var.join_accept_mode) + error_message = "join_accept_mode must be prompt, allowlist, or auto." + } +} + +variable "join_allowlist" { + description = "Usernames allowed to join without confirmation when join_accept_mode is allowlist." + type = list(string) + default = [] + + validation { + condition = alltrue([for username in var.join_allowlist : trimspace(username) != ""]) + error_message = "join_allowlist entries must not be empty." + } +} + +variable "excluded_files" { + description = "Glob patterns for files that OCT must not share with session participants." + type = list(string) + default = ["**/.env"] + + validation { + condition = alltrue([for pattern in var.excluded_files : trimspace(pattern) != ""]) + error_message = "excluded_files entries must not be empty." + } +} + +locals { + normalized_server_url = "${trim(var.server_url, "/")}/" + extension_spec = "${var.extension_id}@${var.extension_version}" +} + +output "extensions" { + description = "Versioned extension identifiers to pass to a compatible web IDE module." + value = var.install_extension ? [local.extension_spec] : [] +} + +output "settings" { + description = "Open Collaboration Tools settings to merge into a compatible IDE module." + value = { + "oct.serverUrl" = local.normalized_server_url + "oct.alwaysAskToOverrideServerUrl" = var.always_ask_to_override_server_url + "oct.joinAcceptMode" = var.join_accept_mode + "oct.joinAllowlist" = var.join_allowlist + "oct.files.exclude" = var.excluded_files + } +} diff --git a/registry/edd88-pixel/modules/open-collaboration-tools/open-collaboration-tools.tftest.hcl b/registry/edd88-pixel/modules/open-collaboration-tools/open-collaboration-tools.tftest.hcl new file mode 100644 index 000000000..ef93e4d69 --- /dev/null +++ b/registry/edd88-pixel/modules/open-collaboration-tools/open-collaboration-tools.tftest.hcl @@ -0,0 +1,156 @@ +run "defaults" { + command = plan + + variables { + server_url = "https://oct.example.com" + } + + assert { + condition = tolist(output.extensions) == tolist(["typefox.open-collaboration-tools@0.3.9"]) + error_message = "The official OCT extension version should be installed by default." + } + + assert { + condition = output.settings["oct.serverUrl"] == "https://oct.example.com/" + error_message = "The server URL should have one trailing slash." + } + + assert { + condition = output.settings["oct.joinAcceptMode"] == "prompt" + error_message = "Join requests should require host confirmation by default." + } + + assert { + condition = tolist(output.settings["oct.files.exclude"]) == tolist(["**/.env"]) + error_message = "Environment files should be excluded by default." + } +} + +run "normalizes_trailing_slash" { + command = plan + + variables { + server_url = "https://oct.example.com/" + } + + assert { + condition = output.settings["oct.serverUrl"] == "https://oct.example.com/" + error_message = "The normalized server URL should contain one trailing slash." + } +} + +run "supports_local_http_server" { + command = plan + + variables { + server_url = "http://127.0.0.1:8100" + } + + assert { + condition = output.settings["oct.serverUrl"] == "http://127.0.0.1:8100/" + error_message = "Local development servers should support HTTP." + } +} + +run "rejects_remote_http_server" { + command = plan + + variables { + server_url = "http://oct.example.com" + } + + expect_failures = [var.server_url] +} + +run "rejects_invalid_extension_id" { + command = plan + + variables { + server_url = "https://oct.example.com" + extension_id = "open-collaboration-tools" + } + + expect_failures = [var.extension_id] +} + +run "rejects_invalid_extension_version" { + command = plan + + variables { + server_url = "https://oct.example.com" + extension_version = "latest" + } + + expect_failures = [var.extension_version] +} + +run "supports_preinstalled_extension" { + command = plan + + variables { + server_url = "https://oct.example.com" + install_extension = false + } + + assert { + condition = length(output.extensions) == 0 + error_message = "No extension should be installed when it is already present in the image." + } +} + +run "supports_allowlist_mode" { + command = plan + + variables { + server_url = "https://oct.example.com" + join_accept_mode = "allowlist" + join_allowlist = ["alice", "bob"] + } + + assert { + condition = output.settings["oct.joinAcceptMode"] == "allowlist" + error_message = "The allowlist join policy should be preserved." + } + + assert { + condition = tolist(output.settings["oct.joinAllowlist"]) == tolist(["alice", "bob"]) + error_message = "The configured join allowlist should be preserved." + } +} + +run "supports_automatic_mode" { + command = plan + + variables { + server_url = "https://oct.example.com" + join_accept_mode = "auto" + } + + assert { + condition = output.settings["oct.joinAcceptMode"] == "auto" + error_message = "The automatic join policy should be preserved." + } +} + +run "rejects_invalid_join_mode" { + command = plan + + variables { + server_url = "https://oct.example.com" + join_accept_mode = "unrestricted" + } + + expect_failures = [var.join_accept_mode] +} + +run "rejects_empty_list_entries" { + command = plan + + variables { + server_url = "https://oct.example.com" + join_allowlist = ["alice", " "] + excluded_files = ["**/.env", ""] + } + + expect_failures = [var.join_allowlist, var.excluded_files] +}