diff --git a/.icons/pade.svg b/.icons/pade.svg
new file mode 100644
index 000000000..3d6ace0a4
--- /dev/null
+++ b/.icons/pade.svg
@@ -0,0 +1,21 @@
+
diff --git a/registry/after-certainty/.images/avatar.svg b/registry/after-certainty/.images/avatar.svg
new file mode 100644
index 000000000..e3e19946b
--- /dev/null
+++ b/registry/after-certainty/.images/avatar.svg
@@ -0,0 +1,4 @@
+
diff --git a/registry/after-certainty/README.md b/registry/after-certainty/README.md
new file mode 100644
index 000000000..6be8aa228
--- /dev/null
+++ b/registry/after-certainty/README.md
@@ -0,0 +1,11 @@
+---
+display_name: "After Certainty"
+bio: "Open-source tools exploring portable development environments, runtime capability intent, and related infrastructure."
+avatar: "./.images/avatar.svg"
+github: "After-Certainty"
+status: "community"
+---
+
+# After Certainty
+
+Open-source tools exploring portable development environments, runtime capability intent, and related infrastructure.
diff --git a/registry/after-certainty/modules/pade/README.md b/registry/after-certainty/modules/pade/README.md
new file mode 100644
index 000000000..f62690f43
--- /dev/null
+++ b/registry/after-certainty/modules/pade/README.md
@@ -0,0 +1,88 @@
+---
+display_name: PADE
+description: Install the PADE CLI and bind a workspace to an existing PADE broker using runtime workload identity
+icon: ../../../../.icons/pade.svg
+verified: false
+tags: [helper, integration, identity, security]
+---
+
+# PADE
+
+Installs the [PADE](https://github.com/After-Certainty/pade) CLI in a Coder workspace and writes broker-only capability bindings, so tools in the workspace can resolve explicitly configured capabilities through an existing PADE broker without durable provider credentials.
+
+```tf
+module "pade" {
+ source = "registry.coder.com/after-certainty/pade/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ broker_endpoint = "https://pade-broker.example.com"
+ broker_capabilities = ["github.repo.read"]
+}
+```
+
+## What it does
+
+On workspace start, the module:
+
+1. downloads the released PADE CLI for the workspace architecture;
+2. verifies the archive against the release `SHA256SUMS`;
+3. installs `pade` to `~/.local/bin`;
+4. writes `~/.config/pade/coder-bindings.yaml` (mode `0600`) containing only the capabilities listed in `broker_capabilities`;
+5. adds `~/.local/bin` to `PATH` and sets `PADE_BINDINGS` in normal shell startup files;
+6. runs `pade --version` to confirm the install.
+
+Coder script lifecycle, synchronization, and logs are handled by `coder-utils`. It materializes the installer at `~/.coder-modules/after-certainty/pade/scripts/install.sh` and writes its output to `~/.coder-modules/after-certainty/pade/logs/install.log`. PADE's binary and bindings retain their tool-specific locations.
+
+`coder-utils` does not block login while installation runs. Wait for the PADE install script to finish before using `pade`. Dependent Coder scripts can use `coder exp sync want ${join(" ", module.pade.scripts)}` to wait for the install pipeline.
+
+Persistent home storage keeps the binary and bindings between starts; ephemeral home storage simply causes them to be recreated.
+
+It does not deploy a PADE broker, define broker-side authorization, or place provider credentials in the workspace or in Terraform state. The bindings file contains only the broker endpoint, audience, identity adapter, and capability names.
+
+`coder-utils` also reads Coder workspace-owner metadata. Its Terraform data-source state can include existing Coder control-plane credentials supplied by the runtime; these are not passed to PADE or used as workload identity. Keep Terraform state protected as for the parent Coder template.
+
+The module-managed bindings file is separate from PADE's default `~/.config/pade/bindings.yaml`, so user-managed bindings are not overwritten.
+
+## Prerequisites
+
+- A Linux workspace with `curl`, `tar`, `sha256sum`, `base64`, `awk`, and `find`.
+- An existing PADE broker reachable over HTTPS that authorizes the workspace's workload identity.
+- A workspace runtime that provides the selected workload identity (for `gce`, the GCE metadata server with an attached service account).
+
+## Proven vs. accepted
+
+Only the GCE path below has been validated end to end through this module. The [pade-coder integration evidence](https://github.com/After-Certainty/pade-coder/tree/main/experiments/001-gce-broker#live-result-after-coder-utils-migration-2026-10-06) records the successful dogfood run after the coder-utils migration:
+
+| Dimension | Proven through this module | Accepted, not yet validated here |
+| ------------------- | -------------------------- | -------------------------------- |
+| Workload identity | `gce` (GCE metadata) | `cursor` |
+| Workspace OS / arch | Linux `amd64` | Linux `arm64` |
+| PADE version | `v0.3.0` | other released versions |
+| Capability | `github.repo.read` | other broker-authorized names |
+
+Accepting `broker_identity = "cursor"` reflects what PADE supports; it does not mean a Cursor-on-Coder path has been validated.
+
+## Identity boundary
+
+Coder owns the workspace lifecycle. The underlying runtime supplies workload identity. PADE consumes that identity and mediates authorized capabilities. The module does not treat Coder control-plane authentication as PADE identity. PADE is not a complete agent security solution.
+
+## Examples
+
+### Explicit broker audience
+
+```tf
+module "pade" {
+ source = "registry.coder.com/after-certainty/pade/coder"
+ version = "1.0.0"
+ agent_id = coder_agent.main.id
+ pade_version = "v0.3.0"
+ broker_endpoint = "https://pade-broker.example.com"
+ broker_audience = "pade-broker"
+ broker_identity = "gce"
+ broker_capabilities = ["github.repo.read"]
+}
+```
+
+### Using the bindings
+
+New shells pick up `PADE_BINDINGS` automatically. Processes that do not source shell startup files can set `PADE_BINDINGS` to the `bindings_path` output or pass that path to `pade` with `--bindings`.
diff --git a/registry/after-certainty/modules/pade/main.test.ts b/registry/after-certainty/modules/pade/main.test.ts
new file mode 100644
index 000000000..a0e4505a1
--- /dev/null
+++ b/registry/after-certainty/modules/pade/main.test.ts
@@ -0,0 +1,439 @@
+import { readFile } from "node:fs/promises";
+import {
+ afterEach,
+ beforeAll,
+ describe,
+ expect,
+ it,
+ setDefaultTimeout,
+} from "bun:test";
+import {
+ execContainer,
+ findResourceInstance,
+ readFileContainer,
+ removeContainer,
+ runContainer,
+ runTerraformApply,
+ runTerraformInit,
+ testRequiredVariables,
+} from "~test";
+
+setDefaultTimeout(120_000);
+
+const IMAGE = "debian:bookworm-slim";
+const STUB_PATH =
+ "/stubs:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
+const RELEASE_URL =
+ "https://github.com/After-Certainty/pade/releases/download/v0.3.0";
+const MODULE_DIR = "/root/.coder-modules/after-certainty/pade";
+const SYNC_NAME = "after-certainty-pade-install_script";
+const BINDINGS_PATH = "/root/.config/pade/coder-bindings.yaml";
+
+const requiredVars = {
+ agent_id: "foo",
+ broker_endpoint: "https://broker.example.com",
+ broker_capabilities: '["github.repo.read"]',
+};
+
+const CODER_STUB = `#!/bin/sh
+[ "$1 $2" = "exp sync" ] || exit 1
+case "$3" in start|complete) ;; *) exit 1 ;; esac
+[ "$4" = "${SYNC_NAME}" ] || exit 1
+echo "$@" >> /tmp/coder.log
+`;
+
+const renderedInstaller = (script: string) => {
+ const encoded = script.match(
+ /echo -n '([^']+)' \| base64 -d > .*\/scripts\/install\.sh/,
+ );
+ expect(encoded).not.toBeNull();
+ return Buffer.from(encoded![1], "base64").toString();
+};
+
+// Serves release assets from /fixtures and records every requested URL.
+const CURL_STUB = `#!/bin/sh
+out=""
+url=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ -o) out="$2"; shift 2 ;;
+ --retry|--connect-timeout|--max-time) shift 2 ;;
+ -*) shift ;;
+ *) url="$1"; shift ;;
+ esac
+done
+echo "$url" >> /tmp/curl.log
+src="/fixtures/$(basename "$url")"
+[ -f "$src" ] || { echo "curl: (22) not found: $url" >&2; exit 22; }
+cp "$src" "$out"
+`;
+
+const UNAME_STUB = `#!/bin/sh
+case "$1" in
+ -s) echo "\${FAKE_UNAME_S:-Linux}" ;;
+ -m) echo "\${FAKE_UNAME_M:-x86_64}" ;;
+ *) exec /usr/bin/uname "$@" ;;
+esac
+`;
+
+const FIXTURES = `mkdir -p /fixtures
+for arch in amd64 arm64; do
+ dir="/build/$arch/pade-v0.3.0-linux-$arch"
+ mkdir -p "$dir"
+ cat > "$dir/pade" <<'EOF'
+#!/bin/sh
+echo "$@" >> /tmp/pade.log
+echo "pade v0.3.0"
+EOF
+ chmod 0755 "$dir/pade"
+ tar -czf "/fixtures/pade-v0.3.0-linux-$arch.tar.gz" -C "/build/$arch" .
+done
+cd /fixtures && sha256sum pade-*.tar.gz > SHA256SUMS
+`;
+
+let cleanupFunctions: (() => Promise)[] = [];
+afterEach(async () => {
+ const cleanups = cleanupFunctions.reverse();
+ cleanupFunctions = [];
+ for (const cleanup of cleanups) {
+ await cleanup();
+ }
+});
+
+const setup = async (
+ vars: Record = {},
+ customEnv: Record = {},
+) => {
+ const state = await runTerraformApply(
+ import.meta.dir,
+ {
+ ...requiredVars,
+ ...vars,
+ },
+ customEnv,
+ );
+ const instance = findResourceInstance(
+ state,
+ "coder_script",
+ "install_script",
+ );
+ const id = await runContainer(IMAGE);
+ cleanupFunctions.push(() => removeContainer(id));
+
+ // Each docker exec is slow on some hosts, so prepare the container in one call.
+ const writeFile = (path: string, content: string, mode: string) =>
+ `echo '${Buffer.from(content).toString("base64")}' | base64 -d > ${path} && chmod ${mode} ${path}`;
+ const bootstrap = await execContainer(id, [
+ "sh",
+ "-c",
+ [
+ "set -eu",
+ "mkdir -p /stubs",
+ writeFile("/stubs/curl", CURL_STUB, "0755"),
+ writeFile("/stubs/uname", UNAME_STUB, "0755"),
+ writeFile("/stubs/coder", CODER_STUB, "0755"),
+ writeFile("/tmp/run.sh", instance.script, "0644"),
+ FIXTURES,
+ ].join("\n"),
+ ]);
+ expect(bootstrap.stderr).toBe("");
+ expect(bootstrap.exitCode).toBe(0);
+
+ const run = (env: Record = {}) =>
+ execContainer(
+ id,
+ ["bash", "/tmp/run.sh"],
+ [
+ "-e",
+ `PATH=${STUB_PATH}`,
+ ...Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]),
+ ],
+ );
+ const sh = (command: string) => execContainer(id, ["sh", "-c", command]);
+
+ return { state, instance, id, run, sh };
+};
+
+describe("pade", () => {
+ beforeAll(async () => {
+ await runTerraformInit(import.meta.dir);
+ });
+
+ testRequiredVariables(import.meta.dir, requiredVars);
+
+ it("uses only coder-utils install orchestration and preserves the rendered installer", async () => {
+ const { state, instance, id, run, sh } = await setup();
+ expect(
+ state.resources
+ .filter((resource) => resource.type === "coder_script")
+ .map((resource) => [resource.module, resource.name]),
+ ).toEqual([["module.coder_utils", "install_script"]]);
+ expect(instance.agent_id).toBe(requiredVars.agent_id);
+ expect(state.outputs.scripts.value).toEqual([SYNC_NAME]);
+ const attributes = state.resources.find(
+ (resource) => resource.type === "coder_script",
+ )!.instances[0].attributes;
+ expect(attributes.display_name).toBe("PADE: Install Script");
+ expect(attributes.run_on_start).toBe(true);
+ expect(attributes.start_blocks_login).toBe(false);
+ const template = await readFile(
+ `${import.meta.dir}/scripts/install.sh.tftpl`,
+ "utf8",
+ );
+ const bindings = Buffer.from(
+ renderedInstaller(instance.script).match(/^BINDINGS_B64='([^']*)'$/m)![1],
+ "base64",
+ ).toString();
+ expect(renderedInstaller(instance.script)).toBe(
+ template
+ .replace("${PADE_VERSION}", "v0.3.0")
+ .replace("${BINDINGS_B64}", Buffer.from(bindings).toString("base64")),
+ );
+ const result = await run();
+ expect(result.exitCode).toBe(0);
+ expect(
+ await readFileContainer(id, `${MODULE_DIR}/scripts/install.sh`),
+ ).toBe(renderedInstaller(instance.script));
+ expect(await readFileContainer(id, `${MODULE_DIR}/logs/install.log`)).toBe(
+ result.stdout,
+ );
+ expect((await sh("cat /tmp/coder.log")).stdout.trim().split("\n")).toEqual([
+ `exp sync start ${SYNC_NAME}`,
+ `exp sync complete ${SYNC_NAME}`,
+ ]);
+ });
+
+ it("installs the amd64 release and runs pade --version", async () => {
+ const { run, sh } = await setup();
+ const result = await run();
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("installing PADE v0.3.0 (linux/amd64)");
+ expect(result.stdout).toContain("pade v0.3.0");
+
+ const requested = await sh("cat /tmp/curl.log");
+ expect(requested.stdout.trim().split("\n")).toEqual([
+ `${RELEASE_URL}/pade-v0.3.0-linux-amd64.tar.gz`,
+ `${RELEASE_URL}/SHA256SUMS`,
+ ]);
+
+ const invocations = await sh("cat /tmp/pade.log");
+ expect(invocations.stdout.trim()).toBe("--version");
+
+ const mode = await sh("stat -c %a /root/.local/bin/pade");
+ expect(mode.stdout.trim()).toBe("755");
+ });
+
+ it("selects the arm64 release on aarch64", async () => {
+ const { run, sh } = await setup();
+ const result = await run({ FAKE_UNAME_M: "aarch64" });
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("(linux/arm64)");
+
+ const requested = await sh("head -n 1 /tmp/curl.log");
+ expect(requested.stdout.trim()).toBe(
+ `${RELEASE_URL}/pade-v0.3.0-linux-arm64.tar.gz`,
+ );
+ });
+
+ it("installs the requested pade_version", async () => {
+ const { run, sh } = await setup({ pade_version: "0.4.0" });
+ const fixtures = await sh(`cd /fixtures
+cp pade-v0.3.0-linux-amd64.tar.gz pade-v0.4.0-linux-amd64.tar.gz
+sha256sum pade-v0.4.0-linux-amd64.tar.gz >> SHA256SUMS`);
+ expect(fixtures.exitCode).toBe(0);
+ const result = await run();
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("installing PADE v0.4.0 (linux/amd64)");
+
+ const requested = await sh("cat /tmp/curl.log");
+ expect(requested.stdout.trim().split("\n")).toEqual([
+ "https://github.com/After-Certainty/pade/releases/download/v0.4.0/pade-v0.4.0-linux-amd64.tar.gz",
+ "https://github.com/After-Certainty/pade/releases/download/v0.4.0/SHA256SUMS",
+ ]);
+ });
+
+ it("rejects unsupported architectures before downloading", async () => {
+ const { run, sh } = await setup();
+ const result = await run({ FAKE_UNAME_M: "riscv64" });
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain(
+ "unsupported workspace architecture: riscv64",
+ );
+ expect((await sh("test -e /tmp/curl.log")).exitCode).not.toBe(0);
+ });
+
+ it("rejects non-Linux workspaces before downloading", async () => {
+ const { run, sh } = await setup();
+ const result = await run({ FAKE_UNAME_S: "Darwin" });
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain(
+ "Linux workspaces are required",
+ );
+ expect((await sh("test -e /tmp/curl.log")).exitCode).not.toBe(0);
+ });
+
+ it("fails on a checksum mismatch without installing", async () => {
+ const { run, sh } = await setup();
+ await sh(
+ "sed -i 's/^[0-9a-f]*\\( pade-v0.3.0-linux-amd64\\)/0000000000000000000000000000000000000000000000000000000000000000\\1/' /fixtures/SHA256SUMS",
+ );
+ const result = await run();
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain("FAILED");
+ expect((await sh("test -e /root/.local/bin/pade")).exitCode).not.toBe(0);
+ expect((await sh(`test -e ${BINDINGS_PATH}`)).exitCode).not.toBe(0);
+ });
+
+ it("fails when the asset is missing from SHA256SUMS", async () => {
+ const { run, sh } = await setup();
+ await sh("sed -i '/linux-amd64/d' /fixtures/SHA256SUMS");
+ const result = await run();
+ expect(result.exitCode).not.toBe(0);
+ expect(result.stdout + result.stderr).toContain(
+ "release checksum does not contain pade-v0.3.0-linux-amd64.tar.gz",
+ );
+ expect((await sh("test -e /root/.local/bin/pade")).exitCode).not.toBe(0);
+ });
+
+ it("writes only the requested broker bindings", async () => {
+ const { id, run, sh } = await setup({
+ broker_endpoint: "https://other-broker.example.com",
+ broker_audience: "pade-broker",
+ broker_identity: "cursor",
+ broker_capabilities: '["github.repo.read", "example.capability"]',
+ });
+ const result = await run();
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain(
+ `broker bindings written to ${BINDINGS_PATH}`,
+ );
+
+ const mode = await sh(`stat -c %a ${BINDINGS_PATH}`);
+ expect(mode.stdout.trim()).toBe("600");
+
+ const broker = {
+ endpoint: "https://other-broker.example.com",
+ audience: "pade-broker",
+ identity: "cursor",
+ };
+ const bindings = Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH));
+ expect(bindings).toEqual({
+ version: "0.1",
+ capabilities: {
+ "github.repo.read": { provider: "broker", broker },
+ "example.capability": { provider: "broker", broker },
+ },
+ });
+ });
+
+ it("configures shell startup files idempotently", async () => {
+ const { run, sh } = await setup();
+ await sh(
+ "mkdir -p /root/.config/fish && touch /root/.bashrc /root/.zshrc /root/.config/fish/config.fish && rm -f /root/.bash_profile /root/.zprofile",
+ );
+ expect((await run()).exitCode).toBe(0);
+ expect((await run()).exitCode).toBe(0);
+
+ const counts = await sh(`cd /root
+for f in .profile .bashrc .zshrc; do
+ echo "$f path $(grep -cxF 'export PATH="$HOME/.local/bin:$PATH"' "$f")"
+ echo "$f bindings $(grep -cxF 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"' "$f")"
+done
+f=.config/fish/config.fish
+echo "fish path $(grep -cxF 'fish_add_path $HOME/.local/bin' "$f")"
+echo "fish bindings $(grep -cxF 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml' "$f")"
+for f in .bash_profile .zprofile; do
+ if [ -e "$f" ]; then echo "$f created"; fi
+done`);
+ expect(counts.stdout.trim().split("\n")).toEqual([
+ ".profile path 1",
+ ".profile bindings 1",
+ ".bashrc path 1",
+ ".bashrc bindings 1",
+ ".zshrc path 1",
+ ".zshrc bindings 1",
+ "fish path 1",
+ "fish bindings 1",
+ ]);
+
+ const login = await sh(
+ "env -i HOME=/root PATH=/usr/bin:/bin bash -lc 'command -v pade && echo \"$PADE_BINDINGS\"'",
+ );
+ expect(login.stdout.trim().split("\n")).toEqual([
+ "/root/.local/bin/pade",
+ BINDINGS_PATH,
+ ]);
+ });
+
+ it("keeps credential material out of state and the installer", async () => {
+ const coderSession = "test-only-coder-session-token";
+ const { state, instance, id, run } = await setup(
+ {},
+ {
+ CODER_WORKSPACE_OWNER_SESSION_TOKEN: coderSession,
+ },
+ );
+ const credentialPattern =
+ /token|secret|password|private key|ghp_|github_pat_|gho_|ghs_/i;
+ const installer = renderedInstaller(instance.script);
+ expect(instance.script).not.toMatch(credentialPattern);
+ expect(installer).not.toMatch(credentialPattern);
+ // Upstream coder-utils reads owner metadata, including Coder's existing
+ // control-plane session token. Allow only that exact fixture in that field;
+ // it must never reach the PADE script, bindings, logs, or other state.
+ const checkValues = (value: unknown): void => {
+ if (typeof value === "string")
+ expect(value).not.toMatch(credentialPattern);
+ else if (Array.isArray(value)) value.forEach(checkValues);
+ else if (value && typeof value === "object")
+ Object.values(value).forEach(checkValues);
+ };
+ const owner = state.resources.find(
+ (resource) =>
+ resource.module === "module.coder_utils" &&
+ resource.mode === "data" &&
+ resource.type === "coder_workspace_owner",
+ );
+ expect(owner).toBeDefined();
+ expect(owner!.instances[0].attributes.session_token).toBe(coderSession);
+ for (const resource of state.resources) {
+ for (const instance of resource.instances) {
+ const { session_token, ...attributes } = instance.attributes;
+ if (resource === owner) {
+ expect(session_token).toBe(coderSession);
+ expect(attributes.ssh_private_key).toBe("");
+ if ("oidc_access_token" in attributes)
+ expect(attributes.oidc_access_token).toBe("");
+ } else checkValues(session_token);
+ checkValues(attributes);
+ }
+ }
+ checkValues(state.outputs);
+ expect((await run()).exitCode).toBe(0);
+ expect(
+ await readFileContainer(id, `${MODULE_DIR}/scripts/install.sh`),
+ ).toBe(installer);
+ expect(
+ await readFileContainer(id, `${MODULE_DIR}/logs/install.log`),
+ ).not.toMatch(credentialPattern);
+
+ const embedded = installer.match(/^BINDINGS_B64='([^']*)'$/m);
+ expect(embedded).not.toBeNull();
+ const bindings = Bun.YAML.parse(
+ Buffer.from(embedded![1], "base64").toString(),
+ );
+ expect(bindings).toEqual({
+ version: "0.1",
+ capabilities: {
+ "github.repo.read": {
+ provider: "broker",
+ broker: {
+ endpoint: "https://broker.example.com",
+ audience: "https://broker.example.com",
+ identity: "gce",
+ },
+ },
+ },
+ });
+ });
+});
diff --git a/registry/after-certainty/modules/pade/main.tf b/registry/after-certainty/modules/pade/main.tf
new file mode 100644
index 000000000..cdb68b317
--- /dev/null
+++ b/registry/after-certainty/modules/pade/main.tf
@@ -0,0 +1,123 @@
+terraform {
+ required_version = ">= 1.0"
+
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.13"
+ }
+ }
+}
+
+variable "agent_id" {
+ description = "The ID of the Coder agent for the workspace that will consume PADE."
+ type = string
+}
+
+variable "pade_version" {
+ description = "PADE release version to install."
+ type = string
+ default = "v0.3.0"
+
+ validation {
+ condition = can(regex("^v?[0-9]+\\.[0-9]+\\.[0-9]+$", var.pade_version))
+ error_message = "pade_version must be a semantic version such as v0.3.0 or 0.3.0."
+ }
+}
+
+variable "broker_endpoint" {
+ description = "The HTTPS endpoint of an existing PADE broker."
+ type = string
+
+ validation {
+ condition = can(regex("^https://", var.broker_endpoint))
+ error_message = "broker_endpoint must be an HTTPS URL."
+ }
+}
+
+variable "broker_audience" {
+ description = "OIDC audience requested for the PADE broker. Defaults to broker_endpoint."
+ type = string
+ default = null
+ nullable = true
+
+ validation {
+ condition = var.broker_audience == null ? true : trimspace(var.broker_audience) != ""
+ error_message = "broker_audience must be null or a non-empty string."
+ }
+}
+
+variable "broker_identity" {
+ description = "PADE workload identity adapter. The GCE path is the integration proven by Experiment 001."
+ type = string
+ default = "gce"
+
+ validation {
+ condition = contains(["gce", "cursor"], var.broker_identity)
+ error_message = "broker_identity must be one of the identity adapters supported by the current PADE Consumer: gce or cursor."
+ }
+}
+
+variable "broker_capabilities" {
+ description = "Capability names that this workspace should resolve through the configured PADE broker."
+ type = set(string)
+
+ validation {
+ condition = (
+ length(var.broker_capabilities) > 0 &&
+ alltrue([for capability in var.broker_capabilities : trimspace(capability) != ""])
+ )
+ error_message = "broker_capabilities must contain at least one non-empty capability name."
+ }
+}
+
+locals {
+ pade_version = startswith(var.pade_version, "v") ? var.pade_version : "v${var.pade_version}"
+ broker_audience = var.broker_audience != null ? var.broker_audience : var.broker_endpoint
+
+ bindings = yamlencode({
+ version = "0.1"
+ capabilities = {
+ for capability in var.broker_capabilities : capability => {
+ provider = "broker"
+ broker = {
+ endpoint = var.broker_endpoint
+ audience = local.broker_audience
+ identity = var.broker_identity
+ }
+ }
+ }
+ })
+}
+
+locals {
+ install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
+ PADE_VERSION = local.pade_version
+ BINDINGS_B64 = base64encode(local.bindings)
+ })
+}
+
+module "coder_utils" {
+ source = "registry.coder.com/coder/coder-utils/coder"
+ version = "0.0.2"
+
+ agent_id = var.agent_id
+ module_directory = "$HOME/.coder-modules/after-certainty/pade"
+ display_name_prefix = "PADE"
+ install_script = local.install_script
+}
+
+output "bindings_path" {
+ description = "Workspace-local path written by this module. Set PADE_BINDINGS to this path or pass it with --bindings."
+ value = "~/.config/pade/coder-bindings.yaml"
+}
+
+output "pade_version" {
+ description = "PADE version installed by the module."
+ value = local.pade_version
+}
+
+output "scripts" {
+ description = "Ordered list of coder exp sync names produced by this module, in run order."
+ value = module.coder_utils.scripts
+}
diff --git a/registry/after-certainty/modules/pade/main.tftest.hcl b/registry/after-certainty/modules/pade/main.tftest.hcl
new file mode 100644
index 000000000..0912174c0
--- /dev/null
+++ b/registry/after-certainty/modules/pade/main.tftest.hcl
@@ -0,0 +1,241 @@
+mock_provider "coder" {}
+
+variables {
+ agent_id = "test-agent-id"
+ broker_endpoint = "https://broker.example.com"
+ broker_capabilities = ["github.repo.read"]
+}
+
+run "defaults" {
+ command = plan
+
+ assert {
+ condition = output.pade_version == "v0.3.0"
+ error_message = "pade_version should default to v0.3.0"
+ }
+
+ assert {
+ condition = output.bindings_path == "~/.config/pade/coder-bindings.yaml"
+ error_message = "bindings_path output should be stable"
+ }
+
+ assert {
+ condition = local.broker_audience == "https://broker.example.com"
+ error_message = "broker_audience should default to broker_endpoint"
+ }
+
+ assert {
+ condition = yamldecode(local.bindings) == {
+ version = "0.1"
+ capabilities = {
+ "github.repo.read" = {
+ provider = "broker"
+ broker = {
+ endpoint = "https://broker.example.com"
+ audience = "https://broker.example.com"
+ identity = "gce"
+ }
+ }
+ }
+ }
+ error_message = "bindings should contain only the requested capability with default broker coordinates"
+ }
+
+ assert {
+ condition = output.scripts == ["after-certainty-pade-install_script"]
+ error_message = "only the install synchronization name should be exposed"
+ }
+
+ assert {
+ condition = strcontains(local.install_script, "PADE_VERSION='v0.3.0'")
+ error_message = "installer should be rendered with the normalized PADE version"
+ }
+
+ assert {
+ condition = strcontains(local.install_script, "BINDINGS_B64='${base64encode(local.bindings)}'")
+ error_message = "installer should embed the generated bindings"
+ }
+}
+
+run "version_normalization" {
+ command = plan
+
+ variables {
+ pade_version = "0.4.1"
+ }
+
+ assert {
+ condition = output.pade_version == "v0.4.1"
+ error_message = "pade_version without a leading v should be normalized"
+ }
+
+ assert {
+ condition = strcontains(local.install_script, "PADE_VERSION='v0.4.1'")
+ error_message = "installer should receive the normalized version"
+ }
+}
+
+run "version_with_prefix_unchanged" {
+ command = plan
+
+ variables {
+ pade_version = "v0.4.1"
+ }
+
+ assert {
+ condition = output.pade_version == "v0.4.1"
+ error_message = "pade_version with a leading v should be preserved"
+ }
+}
+
+run "invalid_version" {
+ command = plan
+
+ variables {
+ pade_version = "latest"
+ }
+
+ expect_failures = [var.pade_version]
+}
+
+run "non_https_endpoint" {
+ command = plan
+
+ variables {
+ broker_endpoint = "http://broker.example.com"
+ }
+
+ expect_failures = [var.broker_endpoint]
+}
+
+run "explicit_audience" {
+ command = plan
+
+ variables {
+ broker_audience = "pade-broker"
+ }
+
+ assert {
+ condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.audience == "pade-broker"
+ error_message = "explicit broker_audience should be written to bindings"
+ }
+
+ assert {
+ condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.endpoint == "https://broker.example.com"
+ error_message = "explicit audience should not change the endpoint"
+ }
+}
+
+run "empty_audience" {
+ command = plan
+
+ variables {
+ broker_audience = " "
+ }
+
+ expect_failures = [var.broker_audience]
+}
+
+run "identity_gce" {
+ command = plan
+
+ variables {
+ broker_identity = "gce"
+ }
+
+ assert {
+ condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.identity == "gce"
+ error_message = "gce identity should be written to bindings"
+ }
+}
+
+run "identity_cursor" {
+ command = plan
+
+ variables {
+ broker_identity = "cursor"
+ }
+
+ assert {
+ condition = yamldecode(local.bindings).capabilities["github.repo.read"].broker.identity == "cursor"
+ error_message = "cursor identity should be accepted and written to bindings"
+ }
+}
+
+run "identity_unsupported" {
+ command = plan
+
+ variables {
+ broker_identity = "aws"
+ }
+
+ expect_failures = [var.broker_identity]
+}
+
+run "empty_capabilities" {
+ command = plan
+
+ variables {
+ broker_capabilities = []
+ }
+
+ expect_failures = [var.broker_capabilities]
+}
+
+run "blank_capability" {
+ command = plan
+
+ variables {
+ broker_capabilities = ["github.repo.read", " "]
+ }
+
+ expect_failures = [var.broker_capabilities]
+}
+
+run "multiple_capabilities" {
+ command = plan
+
+ variables {
+ broker_endpoint = "https://other-broker.example.com"
+ broker_audience = "aud"
+ broker_identity = "cursor"
+ broker_capabilities = ["github.repo.read", "example.capability"]
+ }
+
+ assert {
+ condition = toset(keys(yamldecode(local.bindings).capabilities)) == toset(["example.capability", "github.repo.read"])
+ error_message = "bindings should contain exactly the requested capabilities"
+ }
+
+ assert {
+ condition = alltrue([
+ for capability in values(yamldecode(local.bindings).capabilities) :
+ capability == {
+ provider = "broker"
+ broker = {
+ endpoint = "https://other-broker.example.com"
+ audience = "aud"
+ identity = "cursor"
+ }
+ }
+ ])
+ error_message = "every binding should use the broker provider with the configured endpoint, audience, and identity"
+ }
+
+ assert {
+ condition = toset(keys(yamldecode(local.bindings))) == toset(["capabilities", "version"])
+ error_message = "bindings should contain only version and capabilities"
+ }
+}
+
+run "no_credential_material" {
+ command = plan
+
+ assert {
+ condition = !can(regex(
+ "(?i)(token|secret|password|private key|ghp_|github_pat_|gho_|ghs_)",
+ "${local.install_script}\n${local.bindings}",
+ ))
+ error_message = "installer and bindings must not contain credential material"
+ }
+}
diff --git a/registry/after-certainty/modules/pade/scripts/install.sh.tftpl b/registry/after-certainty/modules/pade/scripts/install.sh.tftpl
new file mode 100644
index 000000000..eed02c531
--- /dev/null
+++ b/registry/after-certainty/modules/pade/scripts/install.sh.tftpl
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PADE_VERSION='${PADE_VERSION}'
+BINDINGS_B64='${BINDINGS_B64}'
+INSTALL_DIR="$HOME/.local/bin"
+BINDINGS_PATH="$HOME/.config/pade/coder-bindings.yaml"
+
+die() {
+ echo "pade-coder: $*" >&2
+ exit 1
+}
+
+for command in curl tar sha256sum base64 awk find; do
+ command -v "$command" > /dev/null 2>&1 || die "required command not found: $command"
+done
+
+case "$(uname -s)" in
+ Linux) os="linux" ;;
+ *) die "Linux workspaces are required" ;;
+esac
+
+case "$(uname -m)" in
+ x86_64 | amd64) arch="amd64" ;;
+ aarch64 | arm64) arch="arm64" ;;
+ *) die "unsupported workspace architecture: $(uname -m)" ;;
+esac
+
+asset="pade-$PADE_VERSION-$os-$arch.tar.gz"
+base_url="https://github.com/After-Certainty/pade/releases/download/$PADE_VERSION"
+tmp="$(mktemp -d)"
+trap 'rm -rf "$tmp"' EXIT
+
+echo "pade-coder: installing PADE $PADE_VERSION ($os/$arch)"
+
+curl --fail --silent --show-error --location --retry 2 \
+ --connect-timeout 10 --max-time 300 \
+ -o "$tmp/$asset" "$base_url/$asset"
+curl --fail --silent --show-error --location --retry 2 \
+ --connect-timeout 10 --max-time 60 \
+ -o "$tmp/SHA256SUMS" "$base_url/SHA256SUMS"
+
+awk -v asset="$asset" '$2 == asset { print }' "$tmp/SHA256SUMS" > "$tmp/SHA256SUMS.asset"
+test -s "$tmp/SHA256SUMS.asset" || die "release checksum does not contain $asset"
+(cd "$tmp" && sha256sum -c SHA256SUMS.asset)
+
+mkdir -p "$tmp/extract" "$INSTALL_DIR" "$(dirname "$BINDINGS_PATH")"
+tar -xzf "$tmp/$asset" -C "$tmp/extract"
+pade_binary="$(find "$tmp/extract" -type f -name pade -print -quit)"
+test -n "$pade_binary" || die "release archive did not contain the pade binary"
+
+cp "$pade_binary" "$INSTALL_DIR/pade"
+chmod 0755 "$INSTALL_DIR/pade"
+
+bindings_tmp="$(mktemp "$BINDINGS_PATH.XXXXXX")"
+printf '%s' "$BINDINGS_B64" | base64 -d > "$bindings_tmp"
+chmod 0600 "$bindings_tmp"
+mv "$bindings_tmp" "$BINDINGS_PATH"
+
+add_shell_exports() {
+ profile="$1"
+ touch "$profile"
+ grep -qF 'export PATH="$HOME/.local/bin:$PATH"' "$profile" \
+ || printf '\n# pade-coder\nexport PATH="$HOME/.local/bin:$PATH"\n' >> "$profile"
+ grep -qF 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"' "$profile" \
+ || printf 'export PADE_BINDINGS="$HOME/.config/pade/coder-bindings.yaml"\n' >> "$profile"
+}
+
+add_shell_exports "$HOME/.profile"
+for profile in "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do
+ if [ -f "$profile" ]; then
+ add_shell_exports "$profile"
+ fi
+done
+
+if [ -f "$HOME/.config/fish/config.fish" ]; then
+ grep -qF 'fish_add_path $HOME/.local/bin' "$HOME/.config/fish/config.fish" \
+ || printf '\n# pade-coder\nfish_add_path $HOME/.local/bin\n' >> "$HOME/.config/fish/config.fish"
+ grep -qF 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml' "$HOME/.config/fish/config.fish" \
+ || printf 'set -gx PADE_BINDINGS $HOME/.config/pade/coder-bindings.yaml\n' >> "$HOME/.config/fish/config.fish"
+fi
+
+export PATH="$INSTALL_DIR:$PATH"
+export PADE_BINDINGS="$BINDINGS_PATH"
+
+"$INSTALL_DIR/pade" --version
+echo "pade-coder: broker bindings written to $BINDINGS_PATH"
+echo "pade-coder: Coder owns workspace lifecycle; workload identity comes from the workspace runtime"