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 @@ + + + AC + 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..23ff3be57 --- /dev/null +++ b/registry/after-certainty/modules/pade/README.md @@ -0,0 +1,185 @@ +--- +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"] +} +``` + +## Direct Git installation + +Before publication to the Registry, the module can be consumed directly from Git: + +```tf +module "pade" { + source = "git::https://github.com/After-Certainty/pade-coder.git//modules/pade" + 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. + +Unlike the previous direct startup script, `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. + +## Dynamic capabilities: trusted downstream bindings + +`broker_capabilities` is optional. When supplied, its static capability names are rendered into broker-only bindings exactly as before. When omitted or set to `[]`, the module installs PADE with an **empty capability map**; demand alone never grants authority. + +The module exposes three generic integration outputs: + +- `bindings_path`: module-managed `~/.config/pade/coder-bindings.yaml` file (expand `~` to the workspace user's home in filesystem operations). +- `broker_binding`: non-secret, operator-configured `provider: broker` template with endpoint, audience, and runtime identity adapter. It is _not_ an authorization grant. +- `scripts`: Coder synchronization names. A dependent Coder script can use `coder exp sync want ${join(" ", module.pade.scripts)}` to wait until PADE installation has finished. + +A Coder template may omit the static capabilities: + +```tf +module "pade" { + source = "git::https://github.com/After-Certainty/pade-coder.git//modules/pade" + agent_id = coder_agent.main.id + broker_endpoint = var.pade_broker_endpoint +} +``` + +A separately owned **trusted downstream integration** should verify or validate its input, apply operator-controlled support and mapping policy, and construct a complete PADE `version: "0.1"` YAML file with `capabilities` containing only approved, mapped capability names. Each resulting value can use `module.pade.broker_binding`. The downstream integration must atomically replace `module.pade.bindings_path` with file mode `0600` **after** the PADE installer completes and **before** dependent PADE workloads start. Do not merge against previous mappings or generate trusted bindings directly from unverified repository content. Missing validation, unsupported demand, failed generation, or failed writing must leave the workspace without usable bindings. + +In dynamic mode, PADE's installer writes the empty map before any network download and again after installation, removing stale bindings even if a subsequent download fails. It intentionally does **not** install `rc-pade` or interpret Runtime Conditions; that belongs in an optional downstream Coder integration. The local file is workspace-user-writable, not a security isolation boundary. The broker still authenticates and authorizes each request; runtime isolation and downstream IAM are separate responsibilities. + +Existing static templates remain compatible. This change does not alter PADE's protocol, broker policy, workload identity, or provider authority. + +## 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 + +The module accepts every value the current PADE Consumer supports, but only one path has been validated end to end through this module: + +| 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` (live); `v0.4.1` (installer CI) | v0.4.1 live broker validation | +| 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. + +## PADE v0.4.1 upgrade validation + +See [the GCE v0.4.1 follow-up runbook](https://github.com/After-Certainty/pade-coder/blob/main/experiments/001-gce-broker/v041-followup.md) for Coder template update steps, a version-gated authenticated smoke, and evidence hygiene. + +The module's **default** PADE release is v0.4.1. Existing templates that explicitly +set `pade_version = "v0.3.0"` or `"v0.4.0"` remain pinned until the template +owner changes that input. This module does not automatically update running +workspaces or reconfigure Cloud Run. + +After updating the Coder template and the GCE-backed workspace, run the +existing manual smoke test from a fresh Coder workspace shell: + +```bash +pade --version +PADE_CODER_EXPECTED_VERSION=v0.4.1 bash experiments/001-gce-broker/run.sh +``` + +The script checks GCE metadata workload identity and broker-mediated +`github.repo.read` against the configured endpoint. Do not log ID tokens +or provider credentials. Record any new v0.4.1 result separately from the +original 2026-10-06 Experiment 001 evidence. GitHub Actions tests installation +and Terraform bindings only; it has no authenticated GCE workload identity. + +## Identity boundary + +```text +Coder workspace (e.g. on GCE) + | + | runtime workload identity (GCE metadata) + v +PADE Consumer -------------> PADE broker +``` + +Coder owns the workspace lifecycle. The underlying runtime supplies workload identity. The module does not treat Coder control-plane authentication as PADE identity. + +## 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.4.1" + 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`. + +## Workload identity and credential exposure + +`broker_capabilities` creates local resolution configuration, not an authorization +grant. The broker verifies the runtime assertion and applies server-owned policy +on every request. Selecting `gce` or `cursor` chooses how the Consumer obtains +identity; it does not translate Coder user identity into a PADE subject. + +GCE workspaces using the same attached service account share the same broker +subject and its allowed capabilities. Coder workspace-owner metadata and RC +provenance do not create per-user isolation. Use distinct runtime identities and +explicit broker policy when that separation is required. The module does not +restrict access to the GCE metadata server or another runtime's identity socket. + +A child receiving credential material can read it and pass it to descendants. +A copied credential can remain usable after `pade exec` or a workspace ends, +subject to downstream expiry/revocation. Output redaction is best effort, not a +sandbox. Reusing a valid workload assertion can obtain fresh material while +server policy allows it. Keep bootstrap authority off the workspace and scope +issued authority in downstream IAM. + +These boundaries were reviewed at pade-coder +`3805842e6d8b65a9c8993def2ec45349fe8a0639` (2026-10-09), alongside the PADE +`docs/security/2026-10-boundary-investigation.md` report. This documentation change +adds no new identity adapter, grant semantics, or runtime behavior. Existing +module tests cover supported/unsupported identity selection and broker-only +bindings; live Coder/GCE validation was not repeated for this review. 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..1dc10f95c --- /dev/null +++ b/registry/after-certainty/modules/pade/main.test.ts @@ -0,0 +1,516 @@ +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.4.1"; +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", +}; + +const staticVars = { + ...requiredVars, + 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 version in v0.4.1 v0.4.0; do + for arch in amd64 arm64; do + dir="/build/$version/$arch/pade-$version-linux-$arch" + mkdir -p "$dir" + cat > "$dir/pade" <<'EOF' +#!/bin/sh +echo "$@" >> /tmp/pade.log +echo "pade __TEST_PADE_VERSION__" +EOF + sed -i "s/__TEST_PADE_VERSION__/$version/g" "$dir/pade" + chmod 0755 "$dir/pade" + tar -czf "/fixtures/pade-$version-linux-$arch.tar.gz" -C "/build/$version/$arch" . + done +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, + { + ...staticVars, + ...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.4.1") + .replace("${BINDINGS_B64}", Buffer.from(bindings).toString("base64")) + .replace("${DYNAMIC_BINDINGS}", "0"), + ); + 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.4.1 (linux/amd64)"); + expect(result.stdout).toContain("pade v0.4.1"); + + const requested = await sh("cat /tmp/curl.log"); + expect(requested.stdout.trim().split("\n")).toEqual([ + `${RELEASE_URL}/pade-v0.4.1-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.4.1-linux-arm64.tar.gz`, + ); + }); + + it("continues to support an explicitly pinned older PADE release", async () => { + const { run, sh } = await setup({ pade_version: "0.4.0" }); + const result = await run(); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("pade v0.4.0"); + + 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.4.1-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.4.1-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("supports trusted downstream bindings without a Terraform-time capability list", async () => { + // A caller can omit broker_capabilities entirely rather than supplying []. + const noStaticState = await runTerraformApply( + import.meta.dir, + requiredVars, + ); + expect(noStaticState.outputs.broker_binding.value).toEqual({ + provider: "broker", + broker: { + endpoint: "https://broker.example.com", + audience: "https://broker.example.com", + identity: "gce", + }, + }); + + const { id, run, sh } = await setup({ broker_capabilities: "[]" }); + expect((await run()).exitCode).toBe(0); + const initiallyEmpty = Bun.YAML.parse( + await readFileContainer(id, BINDINGS_PATH), + ); + expect(initiallyEmpty).toEqual({ version: "0.1", capabilities: {} }); + expect((await sh(`stat -c %a ${BINDINGS_PATH}`)).stdout.trim()).toBe("600"); + + // A trusted downstream script runs only after module.pade.scripts. + // It owns the entire replacement file; portable intent is not trusted. + const writer = await sh(`cat > ${BINDINGS_PATH} <<'EOF' +version: "0.1" +capabilities: + github.repo.read: + provider: broker + broker: + endpoint: https://broker.example.com + audience: https://broker.example.com + identity: gce +EOF +chmod 600 ${BINDINGS_PATH}`); + expect(writer.exitCode).toBe(0); + expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({ + version: "0.1", + capabilities: { + "github.repo.read": noStaticState.outputs.broker_binding.value, + }, + }); + + // On restart, the installer must clear stale downstream bindings, + // so a failed or missing downstream writer cannot retain old access. + expect((await run()).exitCode).toBe(0); + expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({ + version: "0.1", + capabilities: {}, + }); + + // Also revoke stale bindings when the next download fails before install. + expect(writer.exitCode).toBe(0); + await sh(`cat > ${BINDINGS_PATH} <<'EOF' +version: "0.1" +capabilities: + stale.capability: + provider: broker + broker: + endpoint: https://broker.example.com + audience: https://broker.example.com + identity: gce +EOF +chmod 600 ${BINDINGS_PATH}`); + await sh("sed -i '/linux-amd64/d' /fixtures/SHA256SUMS"); + expect((await run()).exitCode).not.toBe(0); + expect(Bun.YAML.parse(await readFileContainer(id, BINDINGS_PATH))).toEqual({ + version: "0.1", + capabilities: {}, + }); + }); + + 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..81e92273f --- /dev/null +++ b/registry/after-certainty/modules/pade/main.tf @@ -0,0 +1,130 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of the Coder agent for the workspace that will consume PADE." +} + +variable "pade_version" { + type = string + description = "PADE release version to install." + default = "v0.4.1" + + 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.4.1 or 0.4.0." + } +} + +variable "broker_endpoint" { + type = string + description = "The HTTPS endpoint of an existing PADE broker." + + validation { + condition = can(regex("^https://", var.broker_endpoint)) + error_message = "broker_endpoint must be an HTTPS URL." + } +} + +variable "broker_audience" { + type = string + description = "OIDC audience requested for the PADE broker. Defaults to broker_endpoint." + 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" { + type = string + description = "PADE workload identity adapter. The GCE path is the integration proven by Experiment 001." + 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" { + type = set(string) + description = "Static capability bindings managed by this module. Leave empty when a trusted downstream integration will populate the bindings after installation." + default = [] + nullable = false + + validation { + condition = alltrue([for capability in var.broker_capabilities : trimspace(capability) != ""]) + error_message = "broker_capabilities must contain only non-empty capability names." + } +} + +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 + + broker_binding = { + provider = "broker" + broker = { + endpoint = var.broker_endpoint + audience = local.broker_audience + identity = var.broker_identity + } + } + + bindings = yamlencode({ + version = "0.1" + capabilities = { + for capability in var.broker_capabilities : capability => local.broker_binding + } + }) +} + +locals { + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + PADE_VERSION = local.pade_version + BINDINGS_B64 = base64encode(local.bindings) + DYNAMIC_BINDINGS = length(var.broker_capabilities) == 0 ? "1" : "0" + }) +} + +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 "broker_binding" { + description = "Non-secret broker binding template for trusted downstream integrations to use when constructing the bindings file. This is configuration, not an authorization grant." + value = local.broker_binding +} + +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..991a5b8cb --- /dev/null +++ b/registry/after-certainty/modules/pade/main.tftest.hcl @@ -0,0 +1,271 @@ +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.4.1" + error_message = "pade_version should default to v0.4.1" + } + + 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 = output.broker_binding == { + provider = "broker" + broker = { + endpoint = "https://broker.example.com" + audience = "https://broker.example.com" + identity = "gce" + } + } + error_message = "broker_binding should expose non-secret broker settings for a trusted downstream writer" + } + + assert { + condition = strcontains(local.install_script, "PADE_VERSION='v0.4.1'") + error_message = "installer should be rendered with the normalized PADE version" + } + + assert { + condition = strcontains(local.install_script, "DYNAMIC_BINDINGS='0'") + error_message = "static bindings should keep the existing install behavior" + } + + 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 = [] + } + + assert { + condition = yamldecode(local.bindings).capabilities == {} + error_message = "an empty capability set must produce a fail-closed empty bindings map" + } + + assert { + condition = strcontains(local.install_script, "DYNAMIC_BINDINGS='1'") + error_message = "the installer must clear stale bindings before downloads in downstream-managed mode" + } + + assert { + condition = strcontains(local.install_script, "BINDINGS_B64='${base64encode(local.bindings)}'") + error_message = "installer must reset bindings to an empty map until a trusted downstream writer runs" + } +} + +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..4b668c0e0 --- /dev/null +++ b/registry/after-certainty/modules/pade/scripts/install.sh.tftpl @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +PADE_VERSION='${PADE_VERSION}' +BINDINGS_B64='${BINDINGS_B64}' +DYNAMIC_BINDINGS='${DYNAMIC_BINDINGS}' +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 + +# Before any network operation, revoke stale locally configured capabilities +# in downstream-managed mode. Missing downloads or installer failures must +# not leave capabilities from a previous successful workspace start in place. +if [ "$DYNAMIC_BINDINGS" = "1" ]; then + mkdir -p "$(dirname "$BINDINGS_PATH")" + bindings_tmp="$(mktemp "$BINDINGS_PATH.XXXXXX")" + printf '%s' "$BINDINGS_B64" | base64 -d > "$bindings_tmp" + chmod 0600 "$bindings_tmp" + mv "$bindings_tmp" "$BINDINGS_PATH" +fi + +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"