From 111a633c8089faaff573ef96a4f699727801b91b Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 01:48:54 -0700 Subject: [PATCH 01/52] feat(workers): install the GitHub CLI in coding agent images Coding agents are routinely asked to work with issues, branches and pull requests, but `gh` was not available in any worker image, so a task that needed it could only fail or fall back to hand-rolled API calls. Install it alongside the other system tools each image already ships (Python, Go, .NET, Java, Maven, Gradle, PowerShell), pinned to 2.100.0 for reproducible builds: - coder-acp-copilot and coder-acp-claude-code: fold the release tarball into the existing toolchain layer, reusing its `ARCH` so amd64 and arm64 both resolve (gh's asset names match `dpkg --print-architecture` exactly). The layer ends in `gh --version` so a bad URL fails the build instead of silently producing an image without it. - coder-acp-copilot-windows: install through Chocolatey next to git. No version check here, because the Chocolatey shim directory only joins PATH via the later ENV, so `gh` is not yet invokable at that layer. `GH_VERSION` is exported at runtime so the agent version registration can report it next to COPILOT_CLI_VERSION. Verified by building the coder-acp-copilot `base` stage and running the resulting image: gh version 2.100.0 (2026-09-03) GH_VERSION=2.100.0 /usr/local/bin/gh Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- apps/workers/coder-acp-claude-code/Dockerfile | 14 +++++++++++++- .../coder-acp-copilot-windows/Dockerfile.base | 6 ++++++ apps/workers/coder-acp-copilot/Dockerfile | 14 +++++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/workers/coder-acp-claude-code/Dockerfile b/apps/workers/coder-acp-claude-code/Dockerfile index e888312ef..6c0a15bb3 100644 --- a/apps/workers/coder-acp-claude-code/Dockerfile +++ b/apps/workers/coder-acp-claude-code/Dockerfile @@ -8,7 +8,10 @@ ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ # which some private registry proxies don't serve; fall back to a pinned global # install (honors npm_config_registry) so restricted networks can still build. RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) -# Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle +# Pinned like every other system tool below so image builds stay reproducible. +ARG GH_VERSION=2.100.0 + +# Install system tools for coding agents: Python, uv, git, GitHub CLI, PowerShell, Go, .NET, Rust, Java, Maven, Gradle RUN apt-get update && apt-get install -y \ python3 \ python3-pip \ @@ -64,9 +67,18 @@ RUN apt-get update && apt-get install -y \ && rm /tmp/gradle.zip \ && chmod +x /opt/gradle/gradle-8.12/bin/gradle \ && ln -s /opt/gradle/gradle-8.12/bin/gradle /usr/local/bin/gradle \ + && curl -fsSL -o /tmp/gh.tar.gz \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${ARCH}.tar.gz" \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && mv "/tmp/gh_${GH_VERSION}_linux_${ARCH}/bin/gh" /usr/local/bin/gh \ + && chmod +x /usr/local/bin/gh \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${ARCH}" \ + && gh --version \ && rm -rf /var/lib/apt/lists/* ENV PATH="/root/.cargo/bin:/root/.local/bin:$PATH" +# Surfaced at runtime so the worker can report the toolchain it actually shipped. +ENV GH_VERSION=${GH_VERSION} # Disable Claude Code "policy skills" (auto-loaded, Anthropic-managed Agent Skills). # As of claude-agent-acp 0.52.0 / claude-agent-sdk 0.3.191 the bundled agent auto-invokes diff --git a/apps/workers/coder-acp-copilot-windows/Dockerfile.base b/apps/workers/coder-acp-copilot-windows/Dockerfile.base index 441af47d6..881f55549 100644 --- a/apps/workers/coder-acp-copilot-windows/Dockerfile.base +++ b/apps/workers/coder-acp-copilot-windows/Dockerfile.base @@ -20,6 +20,12 @@ RUN Invoke-WebRequest -Uri "https://nodejs.org/dist/v${env:NODE_VERSION}/node-v$ # Install git via Chocolatey RUN choco install -y git +# Install GitHub CLI via Chocolatey (pinned to match the Linux worker images) +# No version check here: the chocolatey shim directory only joins PATH via the +# ENV below, so `gh` is not yet invokable at this layer (same as git above). +ARG GH_VERSION=2.100.0 +RUN choco install -y gh --version=$env:GH_VERSION + # Install pnpm as a standalone executable RUN New-Item -ItemType Directory -Force -Path C:\tools | Out-Null; \ Invoke-WebRequest -Uri 'https://github.com/pnpm/pnpm/releases/download/v10.29.1/pnpm-win-x64.exe' \ diff --git a/apps/workers/coder-acp-copilot/Dockerfile b/apps/workers/coder-acp-copilot/Dockerfile index a34e9ccd7..1d184092d 100644 --- a/apps/workers/coder-acp-copilot/Dockerfile +++ b/apps/workers/coder-acp-copilot/Dockerfile @@ -10,7 +10,10 @@ ENV npm_config_registry=${NPM_CONFIG_REGISTRY} \ # which some private registry proxies don't serve; fall back to a pinned global # install (honors npm_config_registry) so restricted networks can still build. RUN corepack enable && (corepack prepare pnpm@10.29.1 --activate || npm install -g pnpm@10.29.1 --force) -# Install system tools for coding agents: Python, uv, git, PowerShell, Go, .NET, Rust, Java, Maven, Gradle +# Pinned like every other system tool below so image builds stay reproducible. +ARG GH_VERSION=2.100.0 + +# Install system tools for coding agents: Python, uv, git, GitHub CLI, PowerShell, Go, .NET, Rust, Java, Maven, Gradle RUN apt-get update && apt-get install -y \ python3 \ python3-pip \ @@ -68,9 +71,18 @@ RUN apt-get update && apt-get install -y \ && rm /tmp/gradle.zip \ && chmod +x /opt/gradle/gradle-8.12/bin/gradle \ && ln -s /opt/gradle/gradle-8.12/bin/gradle /usr/local/bin/gradle \ + && curl -fsSL -o /tmp/gh.tar.gz \ + "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${ARCH}.tar.gz" \ + && tar -xzf /tmp/gh.tar.gz -C /tmp \ + && mv "/tmp/gh_${GH_VERSION}_linux_${ARCH}/bin/gh" /usr/local/bin/gh \ + && chmod +x /usr/local/bin/gh \ + && rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${ARCH}" \ + && gh --version \ && rm -rf /var/lib/apt/lists/* ENV PATH="/root/.cargo/bin:/root/.local/bin:$PATH" +# Surfaced at runtime so the worker can report the toolchain it actually shipped. +ENV GH_VERSION=${GH_VERSION} # --- Builder stage: compile TypeScript --- FROM base AS builder From 8c1dd71e9c65fb52f54ce222ef616f8a578cc135 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 02:20:22 -0700 Subject: [PATCH 02/52] fix(compose): allow agents to use the Docker socket on SELinux hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coder services mount the host Docker socket and set DOCKER_HOST so the agent can run containers during a task. `group_add: ${DOCKER_GID:-0}` gives the right GID, but that is only half of what the socket needs. Where the daemon host enforces SELinux — a podman machine always does, and RHEL/Fedora Docker Engine can — the worker runs as `container_t` while the socket is labelled `var_run_t`, and policy denies the connect. The agent then cannot run containers at all, and the failure is easy to misread: it surfaces as `permission denied ... /var/run/docker.sock`, which looks like a GID problem that DOCKER_GID has already solved. Even `stat` on the socket is denied, which is the tell that it is the label and not the mode. Measured on a podman machine with SELinux enforcing, mounting the socket into the copilot worker image: security_opt group_add 0 result (none) no denied label=disable no denied label=type:container_runtime_t no denied (none) yes denied label=disable yes OK label=type:container_runtime_t yes OK So both are required. Add `security_opt: label=disable` next to the existing group_add. Docker ignores label options on hosts without SELinux, so this is a no-op under Docker Desktop and leaves those setups unchanged. `label=type:container_runtime_t` works equally well and keeps the container confined, which is preferable in principle, but it depends on container-selinux providing that type and fails closed when it does not. For a local development stack the portable option is the better default; the alternative is noted in a comment. Verified by recreating coder-acp-copilot from the compose files alone: the container reports SecurityOpt ["label=disable"], GroupAdd ["0"], and `docker ps` from inside the agent's user now succeeds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- docker-compose.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index fac8bcd69..b63fbd869 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -607,6 +607,15 @@ services: profiles: [claude-code] group_add: - "${DOCKER_GID:-0}" # Docker socket GID (0 for macOS/Docker Desktop, set DOCKER_GID for Linux) + security_opt: + # The right GID is necessary but not sufficient when the daemon host + # enforces SELinux (a podman machine always does; RHEL/Fedora Docker Engine + # can): container_t may not open a var_run_t socket, so the mounted socket + # stays unusable and the agent cannot run containers at all. Ignored on + # hosts without SELinux, such as Docker Desktop. + # label=type:container_runtime_t also works and keeps confinement, but it + # requires container-selinux to provide that type. + - label=disable environment: <<: *worker-env WORKER_NAME: coder-acp-claude-code @@ -713,6 +722,15 @@ services: profiles: [copilot] group_add: - "${DOCKER_GID:-0}" # Docker socket GID (0 for macOS/Docker Desktop, set DOCKER_GID for Linux) + security_opt: + # The right GID is necessary but not sufficient when the daemon host + # enforces SELinux (a podman machine always does; RHEL/Fedora Docker Engine + # can): container_t may not open a var_run_t socket, so the mounted socket + # stays unusable and the agent cannot run containers at all. Ignored on + # hosts without SELinux, such as Docker Desktop. + # label=type:container_runtime_t also works and keeps confinement, but it + # requires container-selinux to provide that type. + - label=disable environment: <<: *worker-env WORKER_NAME: coder-acp-copilot From be78635571ea6de639dfb6b355cdece3c8dedd36 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 14:47:07 -0700 Subject: [PATCH 03/52] feat(shared): add the Resource entity and revision schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Resource declares something that must be made available for a run, together with the lifecycle that provisions and releases it. It may be backed by a container started through the Docker socket, or by something external such as a cloud database — only the script bodies differ, everything downstream is the same. This exists because a run currently has no way to stand up its own environment. MCP servers are registered during worker setup, before the agent executes anything, and registration opens a live connection — so a backend the agent would start itself kills the run before its first turn. A resource's setup phase runs earlier and publishes the connection details that registration then uses. Modelled closely on the codebase entity, which is the other revisioned catalog type: - `revisionCounter` with `latestRevisionId`/`latestRevisionNumber` so concurrent revision creates get distinct, gap-free numbers and the pointer is guarded against stale writes. - The revision carries `createdAt` and a cascade-only `deletedAt`, and deliberately no `updatedAt`: revisions are created and read, never edited. Editing a resource creates a new revision, so a run pinned to one stays reproducible. The response schema omits `deletedAt` entirely. - Script bodies are keyed by interpreter rather than assumed to be `sh`, so adding `powershell` for the Windows worker later is additive instead of breaking. `exports` declares the names the setup phase promises to publish. Declaring them lets the API reject an MCP server referencing an unprovided variable, and lets the worker fail with the missing name instead of registering a server with an unsubstituted placeholder. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- packages/shared/src/schemas/index.ts | 1 + packages/shared/src/schemas/resource.ts | 130 ++++++++++++++++++++++++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/resource.ts | 126 +++++++++++++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 packages/shared/src/schemas/resource.ts create mode 100644 packages/shared/src/types/resource.ts diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 92189d878..cb1df8b4c 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -15,6 +15,7 @@ export * from "./model.js"; export * from "./mcp-server.js"; export * from "./skill.js"; export * from "./codebase.js"; +export * from "./resource.js"; export * from "./extension.js"; export * from "./token.js"; export * from "./account.js"; diff --git a/packages/shared/src/schemas/resource.ts b/packages/shared/src/schemas/resource.ts new file mode 100644 index 000000000..658d09a54 --- /dev/null +++ b/packages/shared/src/schemas/resource.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; + +extendZodWithOpenApi(z); + +/** + * Interpreter a resource's lifecycle scripts are written for. + * + * Only "sh" is executed today. Keyed by interpreter rather than assumed so that + * adding "powershell" for the Windows worker is additive, not breaking. + */ +export const ResourceInterpreterSchema = z.enum(["sh"]); + +/** + * One lifecycle phase body, keyed by interpreter. At least one entry required — + * an empty phase would silently do nothing. + */ +export const ResourceScriptSchema = z + .object({ + sh: z.string().min(1).optional(), + }) + .refine((v) => Object.values(v).some((body) => typeof body === "string" && body.length > 0), { + message: "at least one interpreter body must be provided", + }) + .openapi("ResourceScript"); + +/** + * Exported names must be valid shell environment variable names, because they + * are published as `KEY=VALUE` lines and merged into the agent's environment. + */ +const ExportNameSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/, { + message: "must be a valid environment variable name", +}); + +/** + * Create a resource. The first revision is created atomically with it, so a + * resource can never exist without a lifecycle to run. + */ +export const CreateResourceInputSchema = z + .object({ + name: z.string().min(1), + slug: z.string().min(1).optional(), + description: z.string().optional(), + setup: ResourceScriptSchema, + teardown: ResourceScriptSchema.optional(), + exports: z.array(ExportNameSchema).optional(), + creator: z.string().optional(), + }) + .openapi("CreateResourceInput"); + +/** + * Update the resource's mutable identity only. + * + * Script bodies and exports are deliberately absent: they live on immutable + * revisions, and changing them means creating a new revision. + */ +export const UpdateResourceInputSchema = z + .object({ + name: z.string().min(1).optional(), + description: z.string().optional(), + }) + .openapi("UpdateResourceInput"); + +/** + * Create a new revision of an existing resource. + * + * Deduplicates against the resource's *latest* revision only: a body identical + * to the current latest reuses it rather than inflating the revision number. + * This mirrors the codebase resolver, which compares against `getLatest()` + * rather than doing a global content-addressed lookup. + */ +export const CreateResourceRevisionInputSchema = z + .object({ + setup: ResourceScriptSchema, + teardown: ResourceScriptSchema.optional(), + exports: z.array(ExportNameSchema).optional(), + creator: z.string().optional(), + }) + .openapi("CreateResourceRevisionInput"); + +export const ResourceResponseSchema = z + .object({ + _id: z.string(), + slug: z.string(), + name: z.string(), + description: z.string().optional(), + revisionCounter: z.number(), + latestRevisionId: z.string().optional(), + latestRevisionNumber: z.number().optional(), + creator: z.string().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date().optional(), + deletedAt: z.coerce.date().optional(), + projectId: z.string(), + }) + .openapi("ResourceResponse"); + +/** + * A revision on the wire. + * + * There is no `updatedAt` because revisions are never edited, and no + * `deletedAt` because the cascade flag is an internal housekeeping detail — + * id/ref lookups resolve soft-deleted revisions so historical runs stay + * explainable. + */ +export const ResourceRevisionResponseSchema = z + .object({ + _id: z.string(), + resourceId: z.string(), + slug: z.string(), + revisionNumber: z.number(), + ref: z.string(), + setup: ResourceScriptSchema, + teardown: ResourceScriptSchema.optional(), + exports: z.array(z.string()), + contentSha256: z.string(), + creator: z.string().optional(), + createdAt: z.coerce.date(), + /** + * True when this revision was reused (deduplicated) because the submitted + * bodies matched the current latest, rather than newly created. Only set on + * create responses; absent when listing/fetching revisions. + */ + deduplicated: z.boolean().optional(), + projectId: z.string(), + }) + .openapi("ResourceRevisionResponse"); diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index b30c92512..12c41bccf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -5,6 +5,7 @@ export * from "./types.js"; export * from "./mcp.js"; export * from "./skill.js"; export * from "./codebase.js"; +export * from "./resource.js"; export * from "./extension.js"; export * from "./profile.js"; export * from "./project.js"; diff --git a/packages/shared/src/types/resource.ts b/packages/shared/src/types/resource.ts new file mode 100644 index 000000000..123dcda66 --- /dev/null +++ b/packages/shared/src/types/resource.ts @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// --- Resource types --- + +/** + * Interpreter a resource's lifecycle scripts are written for. + * + * Only "sh" is executed today. The field is an enum rather than an implicit + * default so that adding "powershell" for the Windows worker later is an + * additive change instead of a breaking one. + */ +export type ResourceInterpreter = "sh"; + +/** + * The body of one lifecycle phase, keyed by interpreter. + * + * A worker picks the entry matching its platform. A resource that is referenced + * by a run on a platform it has no body for fails the run loudly — silently + * skipping setup would produce a run that looks valid but has no resource. + */ +export type ResourceScript = Partial>; + +/** + * Resource reference document stored in MongoDB (`resources` collection). + * + * A **mutable** pointer/metadata record for a first-class resource entity: a + * thing that must be made available for a run, together with the lifecycle that + * provisions and releases it. A resource may be backed by a container started + * through the Docker socket, or by something external such as a cloud database + * — only the script bodies differ, everything downstream is identical. + * + * Each resource owns an immutable, incremental revision history + * (`resource-revisions` collection). The `_id` is a fresh UUID; the human + * `slug` is used in CLI/URLs/refs and is unique per project. + */ +export interface ResourceDocument { + _id: string; // Fresh UUID + projectId: string; // FK → ProjectDocument._id (immutable scope) + slug: string; // Unique per project, URL-safe (derived from name) + name: string; // Human-readable display name + description?: string; + /** + * Monotonically increasing counter used to assign each new revision's + * `revisionNumber`. Atomically `$inc`-ed via `findOneAndUpdate` so concurrent + * revision creates receive distinct, gap-free numbers. + */ + revisionCounter: number; + latestRevisionId?: string; // Convenience pointer to the newest revision + latestRevisionNumber?: number; // revisionNumber of latestRevisionId; guards the pointer against stale concurrent writes + creator?: string; // Who created it (provenance) + createdAt: Date; + updatedAt?: Date; + deletedAt?: Date; // Soft-delete timestamp +} + +/** + * Resource revision document stored in MongoDB (`resource-revisions` collection). + * + * An **immutable** snapshot of a resource's lifecycle. Everything that affects + * execution — the script bodies and the exported names — lives here rather than + * on the mutable parent, so a run pinned to a revision stays reproducible after + * the resource is edited. + * + * There is deliberately no `updatedAt`: revisions are created and read, never + * edited. Editing a resource creates a new revision. + * + * The canonical ref is `"{slug}@r{revisionNumber}"`. + */ +export interface ResourceRevisionDocument { + _id: string; // Fresh UUID (one per revision) + resourceId: string; // FK → ResourceDocument._id + projectId: string; // FK → ProjectDocument._id (denormalized from resource) + slug: string; // Denormalized parent slug (for ref building/lookup) + revisionNumber: number; // Sequential per resource (1,2,3…) + ref: string; // Canonical display ref: "{slug}@r{revisionNumber}" + + /** Provisions the resource and publishes its connection details. Required. */ + setup: ResourceScript; + /** Releases the resource. Optional, but omitting it leaks whatever setup created. */ + teardown?: ResourceScript; + /** + * Names this revision's setup phase promises to publish (e.g. "SIMULATOR_URL"). + * + * Declaring them lets the API reject an MCP server referencing `${MCP_URL}` + * when no referenced resource provides it, and lets the worker fail with the + * missing name rather than registering a server with an unsubstituted + * placeholder. + */ + exports: string[]; + + /** + * SHA-256 over the normalized script bodies and exports. Provenance, and the + * key used to detect that a save is identical to the current latest revision. + * Not part of the ref or `_id`. + */ + contentSha256: string; + + // Housekeeping + creator?: string; // Who created the revision (provenance) + createdAt: Date; + /** + * Soft-delete timestamp. Set when the parent resource is soft-deleted + * (cascade). Revisions are never hard-deleted in normal operation so that + * runs referencing this revision keep resolving; lookups by id/ref/number + * intentionally ignore this flag, while listings exclude soft-deleted. + */ + deletedAt?: Date; +} + +/** + * Resolved resource configuration passed to workers at runtime. + * + * Contains the minimal information needed to run the lifecycle phases and to + * validate what the setup phase published. + */ +export interface ResourceConfig { + ref: string; // Revision ref ("{slug}@r{revisionNumber}") + resourceId: string; + revisionId: string; // ResourceRevisionDocument._id + slug: string; + name: string; + setup: ResourceScript; + teardown?: ResourceScript; + exports: string[]; +} From c52e755286d8ade0a647c259eba9e7f06905a4db Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 14:51:37 -0700 Subject: [PATCH 04/52] feat(shared): execute resource lifecycle phases and interpolate their output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pieces the worker needs to stand a resource up and wire it into a run. **resource-env** parses the `$SCOPE_SETUP_ENV` file a setup phase appends its connection details to. A file rather than stdout, so that ordinary script logging — docker progress, curl retries — cannot corrupt the contract. It splits on the first `=` only, because connection strings and URLs routinely contain more; tolerates CRLF; and *reports* malformed lines instead of skipping them, since a dropped line resurfaces much later as an unresolved `${VAR}` far from its cause. It also detects a name published by two resources, which would otherwise make the environment depend on reference order invisibly. **resource-interpolate** substitutes `${VAR}` into MCP server config so a stored record can stay static and reusable (`url: ${MCP_URL}`). It covers every field that can carry a connection detail, which differs by transport: `command`, `args` and `env` for stdio, `url` and header values for http/sse. An unresolved placeholder throws and names every offender at once — passing `${MCP_URL}` through literally fails much later inside the gateway as an opaque transport error. Note the ordering this implies, documented on the module: interpolation must run *after* Token Manager secret hydration, because hydration replaces the whole `env`/`headers` object rather than merging, and would silently undo it. **resource-runner** executes a phase with `sh -e`, a bounded timeout, and stdout and stderr streamed into the run log. `-e` matters: without it a failing command continues into a half-provisioned state that still reports success. Setup runs in reference order and fails the run if a phase does not publish what its revision declared. Teardown runs in reverse so dependants unwind before dependencies, and is best-effort — letting cleanup failure change the run's outcome would mask the result the run actually produced. 32 tests, covering real subprocess execution, the timeout path, export validation, malformed env rejection, and reverse-order teardown. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- .../shared/src/resources/resource-env.test.ts | 185 ++++++++++++++ packages/shared/src/resources/resource-env.ts | 109 ++++++++ .../src/resources/resource-interpolate.ts | 128 ++++++++++ .../src/resources/resource-runner.test.ts | 177 +++++++++++++ .../shared/src/resources/resource-runner.ts | 233 ++++++++++++++++++ 5 files changed, 832 insertions(+) create mode 100644 packages/shared/src/resources/resource-env.test.ts create mode 100644 packages/shared/src/resources/resource-env.ts create mode 100644 packages/shared/src/resources/resource-interpolate.ts create mode 100644 packages/shared/src/resources/resource-runner.test.ts create mode 100644 packages/shared/src/resources/resource-runner.ts diff --git a/packages/shared/src/resources/resource-env.test.ts b/packages/shared/src/resources/resource-env.test.ts new file mode 100644 index 000000000..279648b85 --- /dev/null +++ b/packages/shared/src/resources/resource-env.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { parseResourceEnv, missingExports, exportCollisions } from "./resource-env.js"; +import { + interpolateMcpServerConfig, + interpolateMcpServerConfigs, + referencedPlaceholders, + UnresolvedPlaceholderError, +} from "./resource-interpolate.js"; +import type { McpServerConfig } from "../types/mcp.js"; + +const base: McpServerConfig = { + slug: "github-emulated", + name: "GitHub (emulated)", + type: "http", +}; + +describe("parseResourceEnv", () => { + it("parses simple assignments", () => { + const { values, errors } = parseResourceEnv("A=1\nB=two\n"); + expect(values).toEqual({ A: "1", B: "two" }); + expect(errors).toEqual([]); + }); + + // Connection strings and URLs routinely contain '='; splitting on every '=' + // would silently truncate them. + it("splits on the first = only", () => { + const { values } = parseResourceEnv("URL=http://h/p?a=1&b=2\n"); + expect(values.URL).toBe("http://h/p?a=1&b=2"); + }); + + it("tolerates CRLF", () => { + const { values, errors } = parseResourceEnv("A=1\r\nB=2\r\n"); + expect(values).toEqual({ A: "1", B: "2" }); + expect(errors).toEqual([]); + }); + + it("ignores blank lines and comments", () => { + const { values, errors } = parseResourceEnv("\n# a comment\n\nA=1\n"); + expect(values).toEqual({ A: "1" }); + expect(errors).toEqual([]); + }); + + it("preserves an empty value", () => { + const { values, errors } = parseResourceEnv("EMPTY=\n"); + expect(values).toEqual({ EMPTY: "" }); + expect(errors).toEqual([]); + }); + + // A skipped malformed line would surface far away as an unresolved ${VAR}. + it("reports malformed lines instead of skipping them", () => { + const { values, errors } = parseResourceEnv("GOOD=1\nnonsense\n=novalue\n9BAD=x\n"); + expect(values).toEqual({ GOOD: "1" }); + expect(errors.map((e) => e.line)).toEqual([2, 3, 4]); + expect(errors[0].reason).toMatch(/missing '='/); + expect(errors[1].reason).toMatch(/empty variable name/); + expect(errors[2].reason).toMatch(/invalid variable name/); + }); + + it("lets a later assignment win, like a shell would", () => { + expect(parseResourceEnv("A=1\nA=2\n").values.A).toBe("2"); + }); +}); + +describe("missingExports", () => { + it("names what was promised but not published", () => { + expect(missingExports(["A", "B"], { A: "1" })).toEqual(["B"]); + }); + + it("treats an empty published value as published", () => { + expect(missingExports(["A"], { A: "" })).toEqual([]); + }); +}); + +describe("exportCollisions", () => { + // Last-one-wins would make the environment depend on reference order invisibly. + it("detects a name published by two resources", () => { + const found = exportCollisions([ + { slug: "sim", names: ["URL", "TOKEN"] }, + { slug: "db", names: ["URL"] }, + ]); + expect(found).toEqual([{ name: "URL", slugs: ["sim", "db"] }]); + }); + + it("is quiet when names are disjoint", () => { + expect( + exportCollisions([ + { slug: "sim", names: ["A"] }, + { slug: "db", names: ["B"] }, + ]), + ).toEqual([]); + }); +}); + +describe("interpolateMcpServerConfig", () => { + it("substitutes into url and header values", () => { + const out = interpolateMcpServerConfig( + { + ...base, + url: "${MCP_URL}", + headers: [{ name: "Authorization", value: "Bearer ${SIM_TOKEN}" }], + }, + { MCP_URL: "http://host.docker.internal:18082/mcp", SIM_TOKEN: "ghp_abc" }, + ); + expect(out.url).toBe("http://host.docker.internal:18082/mcp"); + expect(out.headers?.[0].value).toBe("Bearer ghp_abc"); + }); + + it("substitutes into stdio command, args and env", () => { + const out = interpolateMcpServerConfig( + { + ...base, + type: "stdio", + command: "${BIN}", + args: ["--host", "${SIM_URL}"], + env: { GITHUB_HOST: "${SIM_URL}", GITHUB_PERSONAL_ACCESS_TOKEN: "${SIM_TOKEN}" }, + }, + { BIN: "github-mcp-server", SIM_URL: "http://localhost:18080", SIM_TOKEN: "ghp_abc" }, + ); + expect(out.command).toBe("github-mcp-server"); + expect(out.args).toEqual(["--host", "http://localhost:18080"]); + expect(out.env).toEqual({ + GITHUB_HOST: "http://localhost:18080", + GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_abc", + }); + }); + + // Passing ${MCP_URL} through literally fails much later, inside the gateway, + // as an opaque transport error. + it("throws naming every unresolved placeholder at once", () => { + expect(() => + interpolateMcpServerConfig( + { ...base, url: "${MCP_URL}", headers: [{ name: "A", value: "${SIM_TOKEN}" }] }, + { OTHER: "x" }, + ), + ).toThrow(UnresolvedPlaceholderError); + + try { + interpolateMcpServerConfig({ ...base, url: "${MCP_URL}" }, { OTHER: "x" }); + } catch (e) { + const err = e as UnresolvedPlaceholderError; + expect(err.names).toEqual(["MCP_URL"]); + expect(err.serverName).toBe("GitHub (emulated)"); + expect(err.message).toContain("Available: OTHER"); + } + }); + + it("leaves a config with no placeholders untouched", () => { + const cfg = { ...base, url: "http://example.test/mcp" }; + expect(interpolateMcpServerConfig(cfg, {})).toEqual(cfg); + }); + + it("does not invent fields that were absent", () => { + const out = interpolateMcpServerConfig({ ...base, url: "http://x/" }, {}); + expect("env" in out).toBe(false); + expect("headers" in out).toBe(false); + }); +}); + +describe("interpolateMcpServerConfigs", () => { + it("fails the batch if any server is unresolved", () => { + expect(() => + interpolateMcpServerConfigs( + [ + { ...base, slug: "a", url: "http://ok/" }, + { ...base, slug: "b", url: "${NOPE}" }, + ], + {}, + ), + ).toThrow(UnresolvedPlaceholderError); + }); +}); + +describe("referencedPlaceholders", () => { + it("collects names across transports and fields", () => { + expect( + referencedPlaceholders([ + { ...base, url: "${MCP_URL}", headers: [{ name: "A", value: "Bearer ${TOK}" }] }, + { ...base, slug: "s", type: "stdio", args: ["${ARG}"], env: { X: "${TOK}" } }, + ]), + ).toEqual(["ARG", "MCP_URL", "TOK"]); + }); +}); diff --git a/packages/shared/src/resources/resource-env.ts b/packages/shared/src/resources/resource-env.ts new file mode 100644 index 000000000..7e7d91922 --- /dev/null +++ b/packages/shared/src/resources/resource-env.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Resource connection-detail parsing. + * + * A resource's setup phase publishes its connection details by appending + * `KEY=VALUE` lines to the file whose path the worker passes as + * `$SCOPE_SETUP_ENV`. A file is used rather than parsing stdout so that ordinary + * logging from the script — `docker` progress, `curl` retries — cannot corrupt + * the contract. + */ + +/** A malformed line in an env file, reported with its 1-based line number. */ +export interface EnvParseError { + line: number; + text: string; + reason: string; +} + +export interface ParseEnvResult { + values: Record; + errors: EnvParseError[]; +} + +/** Valid shell environment variable name. */ +const NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Parse the contents of a `$SCOPE_SETUP_ENV` file. + * + * Rules, each chosen to avoid a silent misread: + * - Split on the **first** `=` only, so values may contain `=` (connection + * strings and tokens routinely do). + * - Tolerate CRLF as well as LF. + * - Ignore blank lines and `#` comments. + * - Collect malformed lines as errors rather than skipping them. A dropped line + * would surface much later as an unresolved `${VAR}`, far from its cause. + * + * A later assignment to the same name wins, matching shell semantics. + */ +export function parseResourceEnv(contents: string): ParseEnvResult { + const values: Record = {}; + const errors: EnvParseError[] = []; + + const lines = contents.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + const trimmed = raw.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + + const eq = trimmed.indexOf("="); + if (eq === -1) { + errors.push({ line: i + 1, text: raw, reason: "missing '='" }); + continue; + } + + const name = trimmed.slice(0, eq).trim(); + if (!NAME_RE.test(name)) { + errors.push({ + line: i + 1, + text: raw, + reason: name === "" ? "empty variable name" : `invalid variable name '${name}'`, + }); + continue; + } + + values[name] = trimmed.slice(eq + 1); + } + + return { values, errors }; +} + +/** + * Check that a setup phase published everything its revision promised. + * + * Returns the names that were declared in `exports` but never assigned. The + * caller fails the run with these, rather than letting the omission surface + * later as an unresolved `${VAR}` inside an MCP registration error. + */ +export function missingExports(declared: string[], values: Record): string[] { + return declared.filter((name) => !(name in values)); +} + +/** + * Detect names published by more than one resource. + * + * Two resources publishing the same name would make the resulting environment + * depend on reference order, invisibly. That is treated as an error rather than + * last-one-wins. + * + * @param published - per-resource published names, in reference order. + * @returns each colliding name with the slugs that published it. + */ +export function exportCollisions( + published: Array<{ slug: string; names: string[] }>, +): Array<{ name: string; slugs: string[] }> { + const owners = new Map(); + for (const { slug, names } of published) { + for (const name of names) { + const list = owners.get(name); + if (list) list.push(slug); + else owners.set(name, [slug]); + } + } + return [...owners.entries()] + .filter(([, slugs]) => slugs.length > 1) + .map(([name, slugs]) => ({ name, slugs })); +} diff --git a/packages/shared/src/resources/resource-interpolate.ts b/packages/shared/src/resources/resource-interpolate.ts new file mode 100644 index 000000000..25765969b --- /dev/null +++ b/packages/shared/src/resources/resource-interpolate.ts @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { McpServerConfig } from "../types/mcp.js"; + +/** + * `${VAR}` interpolation of resource connection details into MCP server config. + * + * A stored MCP server record stays static and reusable by referring to a + * resource's published names rather than a concrete address: + * + * url: ${MCP_URL} + * headers: Authorization: Bearer ${SIM_TOKEN} + * + * **Ordering matters.** This must run *after* Token Manager secret hydration and + * *before* registration with the gateway. Hydration replaces the whole `env` or + * `headers` object rather than merging into it, so interpolating any earlier is + * silently undone — which presents as "interpolation doesn't work" with nothing + * in the logs to explain it. + */ + +/** Thrown when a placeholder has no corresponding published value. */ +export class UnresolvedPlaceholderError extends Error { + readonly names: string[]; + readonly serverName: string; + + constructor(serverName: string, names: string[], available: string[]) { + super( + `MCP server '${serverName}' references ${names.map((n) => `\${${n}}`).join(", ")}, ` + + `which no referenced resource published. ` + + (available.length > 0 + ? `Available: ${available.join(", ")}.` + : `No resource published any values.`), + ); + this.name = "UnresolvedPlaceholderError"; + this.names = names; + this.serverName = serverName; + } +} + +const PLACEHOLDER_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g; + +/** Collect every placeholder name used in a string. */ +function placeholdersIn(value: string): string[] { + return [...value.matchAll(PLACEHOLDER_RE)].map((m) => m[1]); +} + +/** + * Substitute `${VAR}` in one string. + * + * Unknown names are left intact and reported via `missing` so the caller can + * fail with every offending name at once rather than one per attempt. + */ +function substitute(value: string, values: Record, missing: Set): string { + return value.replace(PLACEHOLDER_RE, (whole, name: string) => { + if (name in values) return values[name]; + missing.add(name); + return whole; + }); +} + +/** + * Interpolate published resource values into one MCP server config. + * + * Covers every field that can carry a connection detail, which differs by + * transport: + * - `stdio` — `command`, `args[]`, `env` values. The gateway launches the + * process with that env, so this is how e.g. `GITHUB_HOST` reaches it. + * - `http`/`sse` — `url` and `headers[].value`. + * + * @throws {UnresolvedPlaceholderError} if any placeholder has no value. Failing + * here is deliberate: a literal `${MCP_URL}` passed through to the gateway fails + * much later as an opaque transport error. + */ +export function interpolateMcpServerConfig( + config: McpServerConfig, + values: Record, +): McpServerConfig { + const missing = new Set(); + const sub = (v: string) => substitute(v, values, missing); + + const next: McpServerConfig = { + ...config, + ...(config.url !== undefined ? { url: sub(config.url) } : {}), + ...(config.command !== undefined ? { command: sub(config.command) } : {}), + ...(config.args !== undefined ? { args: config.args.map(sub) } : {}), + ...(config.env !== undefined + ? { env: Object.fromEntries(Object.entries(config.env).map(([k, v]) => [k, sub(v)])) } + : {}), + ...(config.headers !== undefined + ? { headers: config.headers.map((h) => ({ ...h, value: sub(h.value) })) } + : {}), + }; + + if (missing.size > 0) { + throw new UnresolvedPlaceholderError(config.name, [...missing], Object.keys(values).sort()); + } + return next; +} + +/** Interpolate a whole set of MCP server configs. */ +export function interpolateMcpServerConfigs( + configs: McpServerConfig[], + values: Record, +): McpServerConfig[] { + return configs.map((c) => interpolateMcpServerConfig(c, values)); +} + +/** + * Every placeholder name referenced across a set of MCP server configs. + * + * Lets a caller check up front that referenced resources can satisfy them, + * rather than discovering it only once the setup phases have already run. + */ +export function referencedPlaceholders(configs: McpServerConfig[]): string[] { + const names = new Set(); + for (const c of configs) { + const strings = [ + c.url, + c.command, + ...(c.args ?? []), + ...Object.values(c.env ?? {}), + ...(c.headers ?? []).map((h) => h.value), + ].filter((v): v is string => typeof v === "string"); + for (const s of strings) for (const n of placeholdersIn(s)) names.add(n); + } + return [...names].sort(); +} diff --git a/packages/shared/src/resources/resource-runner.test.ts b/packages/shared/src/resources/resource-runner.test.ts new file mode 100644 index 000000000..60d1e0225 --- /dev/null +++ b/packages/shared/src/resources/resource-runner.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from "vitest"; +import { tmpdir } from "node:os"; +import { + runResourceSetups, + runResourceTeardowns, + selectPhaseBody, + ResourcePhaseError, +} from "./resource-runner.js"; +import type { ResourceConfig } from "../types/resource.js"; + +function resource(over: Partial & Pick): ResourceConfig { + return { + ref: `${over.slug}@r1`, + resourceId: `id-${over.slug}`, + revisionId: `rev-${over.slug}`, + name: over.slug, + setup: { sh: "true" }, + exports: [], + ...over, + } as ResourceConfig; +} + +const opts = { cwd: tmpdir() }; + +describe("selectPhaseBody", () => { + it("returns undefined when the phase is absent", () => { + expect(selectPhaseBody(undefined, "s", "teardown")).toBeUndefined(); + }); + + // Skipping silently would give a run that looks valid but has no resource. + it("throws when the phase exists but has no sh body", () => { + expect(() => selectPhaseBody({} as never, "s", "setup")).toThrow(ResourcePhaseError); + }); +}); + +describe("runResourceSetups", () => { + it("publishes values written to $SCOPE_SETUP_ENV", async () => { + const { values } = await runResourceSetups( + [ + resource({ + slug: "sim", + setup: { sh: 'echo "SIM_URL=http://localhost:18080" >> "$SCOPE_SETUP_ENV"' }, + exports: ["SIM_URL"], + }), + ], + opts, + ); + expect(values).toEqual({ SIM_URL: "http://localhost:18080" }); + }); + + it("merges values across resources in order", async () => { + const { values, provisioned } = await runResourceSetups( + [ + resource({ slug: "a", setup: { sh: 'echo "A=1" >> "$SCOPE_SETUP_ENV"' }, exports: ["A"] }), + resource({ slug: "b", setup: { sh: 'echo "B=2" >> "$SCOPE_SETUP_ENV"' }, exports: ["B"] }), + ], + opts, + ); + expect(values).toEqual({ A: "1", B: "2" }); + expect(provisioned.map((r) => r.slug)).toEqual(["a", "b"]); + }); + + it("fails the run when the script exits non-zero", async () => { + await expect( + runResourceSetups([resource({ slug: "bad", setup: { sh: "exit 3" } })], opts), + ).rejects.toThrow(ResourcePhaseError); + }); + + // `sh -e`: a failing command must abort rather than continue into a + // half-provisioned state that reports success. + it("aborts on the first failing command", async () => { + await expect( + runResourceSetups( + [resource({ slug: "e", setup: { sh: 'false\necho "A=1" >> "$SCOPE_SETUP_ENV"' }, exports: ["A"] })], + opts, + ), + ).rejects.toThrow(ResourcePhaseError); + }); + + // The omission would otherwise surface much later as an unresolved ${VAR} + // inside an MCP registration failure. + it("fails when a declared export is not published", async () => { + await expect( + runResourceSetups( + [resource({ slug: "sim", setup: { sh: "true" }, exports: ["SIM_URL"] })], + opts, + ), + ).rejects.toThrow(/did not publish declared exports: SIM_URL/); + }); + + it("reports the failing resource so the caller can unwind the prefix", async () => { + const started: string[] = []; + await expect( + runResourceSetups( + [ + resource({ slug: "ok", setup: { sh: "true" } }), + resource({ slug: "bad", setup: { sh: "exit 1" } }), + ], + { ...opts, log: (_l, m) => void started.push(m) }, + ), + ).rejects.toThrow(ResourcePhaseError); + expect(started.some((m) => m.includes("'ok'"))).toBe(true); + expect(started.some((m) => m.includes("'bad'"))).toBe(true); + }); + + it("rejects a malformed $SCOPE_SETUP_ENV rather than dropping the line", async () => { + await expect( + runResourceSetups( + [resource({ slug: "m", setup: { sh: 'echo "nonsense" >> "$SCOPE_SETUP_ENV"' } })], + opts, + ), + ).rejects.toThrow(/malformed \$SCOPE_SETUP_ENV/); + }); + + it("times out a hanging phase", async () => { + await expect( + runResourceSetups([resource({ slug: "slow", setup: { sh: "sleep 30" } })], { + ...opts, + timeoutMs: 300, + }), + ).rejects.toThrow(/timed out/); + }, 10_000); + + it("passes extra environment through to the script", async () => { + const { values } = await runResourceSetups( + [ + resource({ + slug: "env", + setup: { sh: 'echo "SEEN=$MY_VAR" >> "$SCOPE_SETUP_ENV"' }, + exports: ["SEEN"], + }), + ], + { ...opts, env: { MY_VAR: "from-worker" } }, + ); + expect(values.SEEN).toBe("from-worker"); + }); +}); + +describe("runResourceTeardowns", () => { + it("releases in reverse order so dependants unwind first", async () => { + const order: string[] = []; + await runResourceTeardowns( + [ + resource({ slug: "first", teardown: { sh: "true" } }), + resource({ slug: "second", teardown: { sh: "true" } }), + ], + { ...opts, log: (_l, m) => void (m.includes("Releasing") && order.push(m)) }, + ); + expect(order[0]).toContain("'second'"); + expect(order[1]).toContain("'first'"); + }); + + // Cleanup failure must not mask the result the run actually produced. + it("keeps going when one teardown fails, and does not throw", async () => { + const logs: string[] = []; + await expect( + runResourceTeardowns( + [ + resource({ slug: "a", teardown: { sh: "true" } }), + resource({ slug: "b", teardown: { sh: "exit 1" } }), + ], + { ...opts, log: (_l, m) => void logs.push(m) }, + ), + ).resolves.toBeUndefined(); + expect(logs.some((m) => m.includes("teardown failed, continuing"))).toBe(true); + expect(logs.some((m) => m.includes("Releasing resource 'a'"))).toBe(true); + }); + + it("skips resources with no teardown phase", async () => { + await expect( + runResourceTeardowns([resource({ slug: "none" })], opts), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/shared/src/resources/resource-runner.ts b/packages/shared/src/resources/resource-runner.ts new file mode 100644 index 000000000..d1a08d578 --- /dev/null +++ b/packages/shared/src/resources/resource-runner.ts @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn } from "node:child_process"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ResourceConfig, ResourceScript } from "../types/resource.js"; +import { parseResourceEnv, missingExports } from "./resource-env.js"; + +/** + * Execution of a resource's lifecycle phases. + * + * The setup phase provisions whatever the run needs — typically by starting + * containers through the Docker socket — and publishes its connection details by + * appending `KEY=VALUE` lines to `$SCOPE_SETUP_ENV`. The teardown phase releases + * it again. + * + * Only `sh` is executed today. The body is selected by interpreter rather than + * assumed, so adding PowerShell for the Windows worker stays additive. + */ + +/** Default ceiling for one phase. A container pull plus a repo import legitimately takes a minute. */ +export const DEFAULT_PHASE_TIMEOUT_MS = 10 * 60_000; + +export type LogFn = (level: "info" | "warn" | "error", message: string) => void | Promise; + +export interface RunPhaseOptions { + /** Working directory for the script. Normally the run's workspace. */ + cwd: string; + /** Extra environment for the script, on top of the worker's own. */ + env?: Record; + timeoutMs?: number; + log?: LogFn; + /** Abort in-flight execution (worker shutdown). */ + signal?: AbortSignal; +} + +export interface PhaseResult { + /** Values the phase published via `$SCOPE_SETUP_ENV`. Empty for teardown. */ + values: Record; + exitCode: number; + durationMs: number; +} + +export class ResourcePhaseError extends Error { + readonly slug: string; + readonly phase: "setup" | "teardown"; + readonly exitCode: number | null; + + constructor(slug: string, phase: "setup" | "teardown", exitCode: number | null, detail: string) { + super(`Resource '${slug}' ${phase} failed${exitCode === null ? "" : ` (exit ${exitCode})`}: ${detail}`); + this.name = "ResourcePhaseError"; + this.slug = slug; + this.phase = phase; + this.exitCode = exitCode; + } +} + +/** + * Pick the body for this platform. + * + * Returns undefined when the phase is absent entirely (a resource may legitimately + * have no teardown). Throws when the phase exists but has no body for this + * interpreter — that is a misconfiguration, and skipping it silently would give a + * run that looks valid but has no resource. + */ +export function selectPhaseBody( + script: ResourceScript | undefined, + slug: string, + phase: "setup" | "teardown", +): string | undefined { + if (!script) return undefined; + const body = script.sh; + if (typeof body === "string" && body.length > 0) return body; + throw new ResourcePhaseError( + slug, + phase, + null, + `no 'sh' body (declared interpreters: ${Object.keys(script).join(", ") || "none"})`, + ); +} + +/** Run one phase body, returning anything it published. */ +async function runScript( + slug: string, + phase: "setup" | "teardown", + body: string, + options: RunPhaseOptions, +): Promise { + const { cwd, env = {}, timeoutMs = DEFAULT_PHASE_TIMEOUT_MS, log, signal } = options; + const started = Date.now(); + + const dir = await mkdtemp(join(tmpdir(), `scope-resource-${slug}-`)); + const scriptPath = join(dir, `${phase}.sh`); + const envPath = join(dir, "exports.env"); + + try { + await writeFile(scriptPath, body, "utf-8"); + await writeFile(envPath, "", "utf-8"); + + const exitCode = await new Promise((resolve, reject) => { + // `-e` so a failing command aborts the phase rather than continuing into a + // half-provisioned state that looks successful. + const child = spawn("sh", ["-e", scriptPath], { + cwd, + env: { ...process.env, ...env, SCOPE_SETUP_ENV: envPath }, + stdio: ["ignore", "pipe", "pipe"], + signal, + }); + + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new ResourcePhaseError(slug, phase, null, `timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + const stream = (chunk: Buffer, level: "info" | "warn") => { + for (const line of chunk.toString().split(/\r?\n/)) { + if (line.trim() !== "") void log?.(level, `[resource:${slug}:${phase}] ${line}`); + } + }; + child.stdout.on("data", (c: Buffer) => stream(c, "info")); + child.stderr.on("data", (c: Buffer) => stream(c, "warn")); + + child.on("error", (err) => { + clearTimeout(timer); + reject(new ResourcePhaseError(slug, phase, null, err.message)); + }); + child.on("close", (code) => { + clearTimeout(timer); + resolve(code ?? -1); + }); + }); + + if (exitCode !== 0) { + throw new ResourcePhaseError(slug, phase, exitCode, "see the run log for script output"); + } + + const contents = await readFile(envPath, "utf-8"); + const { values, errors } = parseResourceEnv(contents); + if (errors.length > 0) { + const detail = errors.map((e) => `line ${e.line}: ${e.reason}`).join("; "); + throw new ResourcePhaseError(slug, phase, exitCode, `malformed $SCOPE_SETUP_ENV — ${detail}`); + } + + return { values, exitCode, durationMs: Date.now() - started }; + } finally { + await rm(dir, { recursive: true, force: true }).catch(() => {}); + } +} + +/** + * Provision every resource, in reference order. + * + * Returns the merged published values. On failure, the caller is responsible for + * tearing down whatever already succeeded — `provisioned` reports that prefix so + * it can unwind in reverse. + */ +export async function runResourceSetups( + resources: ResourceConfig[], + options: RunPhaseOptions, +): Promise<{ values: Record; provisioned: ResourceConfig[] }> { + const values: Record = {}; + const provisioned: ResourceConfig[] = []; + + for (const resource of resources) { + const body = selectPhaseBody(resource.setup, resource.slug, "setup"); + if (!body) { + throw new ResourcePhaseError(resource.slug, "setup", null, "resource has no setup phase"); + } + + void options.log?.("info", `Provisioning resource '${resource.slug}' (${resource.ref})`); + // Marked provisioned before running: a phase that fails partway may still + // have created containers, so its teardown must run. + provisioned.push(resource); + const result = await runScript(resource.slug, "setup", body, options); + + const missing = missingExports(resource.exports, result.values); + if (missing.length > 0) { + throw new ResourcePhaseError( + resource.slug, + "setup", + result.exitCode, + `did not publish declared exports: ${missing.join(", ")}`, + ); + } + + Object.assign(values, result.values); + void options.log?.( + "info", + `Resource '${resource.slug}' ready in ${result.durationMs}ms` + + (resource.exports.length > 0 ? ` (published ${resource.exports.join(", ")})` : ""), + ); + } + + return { values, provisioned }; +} + +/** + * Release resources in **reverse** order, so dependants unwind before their + * dependencies. + * + * Teardown is best-effort: a failure is logged and the remaining resources are + * still released. Letting cleanup failure change the run's reported outcome would + * mask the result the run actually produced. + */ +export async function runResourceTeardowns( + resources: ResourceConfig[], + options: RunPhaseOptions, +): Promise { + for (const resource of [...resources].reverse()) { + let body: string | undefined; + try { + body = selectPhaseBody(resource.teardown, resource.slug, "teardown"); + } catch (err) { + void options.log?.("warn", `Resource '${resource.slug}' teardown skipped: ${String(err)}`); + continue; + } + if (!body) continue; + + try { + void options.log?.("info", `Releasing resource '${resource.slug}'`); + await runScript(resource.slug, "teardown", body, options); + } catch (err) { + void options.log?.( + "warn", + `Resource '${resource.slug}' teardown failed, continuing: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } +} From 7484fe9b0f0e0ae1a51767eb7e6fb47503b02166 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 14:56:37 -0700 Subject: [PATCH 05/52] feat(coder-acp-copilot): provision resources before MCP registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the resource lifecycle into the Copilot worker. Three orderings matter here, and each of them is a bug if it moves. **Resource setup runs before MCP registration.** This is the point of the feature. Registration opens a live connection to the MCP server and throws if it is unreachable, so a server backed by something the run brings up itself could never be registered — setup() failed before the agent's first turn. Provisioning first means the address exists by the time registration needs it. **Interpolation happens immediately before registration**, not earlier. The queue processor hydrates MCP configs with plaintext secrets from Token Manager, and that hydration *replaces* the whole env/headers object rather than merging into it. Substituting `${VAR}` any earlier is silently undone, which presents as "interpolation doesn't work" with nothing in the logs to explain it. **Resource teardown runs before the container purge.** The purge in teardown() would otherwise destroy the very containers a teardown script is about to remove, leaving it to fail or silently no-op. The orphan purge in setup() stays where it is, before resource setup, so a resource publishing a fixed port cannot inherit a stale container still holding it. Published connection details are also merged into the agent's subprocess environment. buildSubprocessEnv constructs a fixed object and never spreads process.env, so this is the only way an agent-facing tool can learn where the run's resources are. The resource values are spread FIRST so the fixed keys win: a resource must not be able to shadow GITHUB_TOKEN, which is the CLI's own auth, nor the proxy settings that route model traffic for capture. Adds a Docker socket preflight check. Without it a container-backed resource fails inside its own script with a raw `permission denied ... docker.sock`, which reads like a group-ownership problem even when it is an SELinux label denial — an expensive thing to misdiagnose, so the error names that possibility. Failure during setup unwinds whatever already came up, in reverse, rather than leaving a half-provisioned environment to leak into the next run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- apps/workers/coder-acp-copilot/src/index.ts | 110 +++++++++++++++++++- packages/shared/src/index.ts | 1 + packages/shared/src/resources/index.ts | 6 ++ packages/shared/src/types/types.ts | 5 + 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 packages/shared/src/resources/index.ts diff --git a/apps/workers/coder-acp-copilot/src/index.ts b/apps/workers/coder-acp-copilot/src/index.ts index 6ec452b12..52fcac412 100644 --- a/apps/workers/coder-acp-copilot/src/index.ts +++ b/apps/workers/coder-acp-copilot/src/index.ts @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, WorkerLogFn, TokenManagerClient, createProxyClient, isProxyEnabled, type ProxyClient, McpGatewayClient, McpServerConfig, KubedockClient, createFreshWorkspace, cleanupWorkspaces } from "shared"; +import { CodingAgentQueueProcessor, WorkerProcessor, WorkerProcessorOptions, WorkerResult, QueueProcessorConfig, LogEvent, WorkerLogFn, TokenManagerClient, createProxyClient, isProxyEnabled, type ProxyClient, McpGatewayClient, McpServerConfig, KubedockClient, createFreshWorkspace, cleanupWorkspaces, type ResourceConfig, runResourceSetups, runResourceTeardowns, interpolateMcpServerConfigs, referencedPlaceholders } from "shared"; import { initTelemetry, trackMetric, trackTrace, trackEvent } from "telemetry"; import { runACPSession } from "./acp-client.js"; +import { access, constants as fsConstants } from "node:fs/promises"; import dotenv from "dotenv"; dotenv.config(); @@ -64,6 +65,7 @@ export function buildSubprocessEnv( gatewayUrl?: string, proxyUrl?: string, certPath?: string, + resourceEnv?: Record, ): Record { const gatewayHost = gatewayUrl ? new URL(gatewayUrl).hostname : null; const noProxy = [ @@ -77,6 +79,14 @@ export function buildSubprocessEnv( ...(gatewayHost ? [gatewayHost] : []), ].join(","); return { + // Connection details published by the run's resources. This is the only way + // an agent-facing tool can learn where its resources live: every other key + // here is fixed, and process.env is deliberately not spread. + // + // Spread FIRST so the fixed keys below win. A resource must not be able to + // shadow GITHUB_TOKEN — that is the CLI's own auth, not the simulator's — + // nor the proxy settings, which are what route model traffic for capture. + ...(resourceEnv ?? {}), GITHUB_TOKEN: githubToken, // Disable the Copilot CLI in-session auto-updater. In headless --acp --yolo // mode it downloads a newer binary mid-run, logs "restart to update", and then @@ -121,6 +131,11 @@ class CopilotProcessor implements WorkerProcessor { private gateway: McpGatewayClient | null = null; private mcpConfigs: McpServerConfig[] = []; private kubedock: KubedockClient | null = null; + private resourceConfigs: ResourceConfig[] = []; + /** Resources actually brought up this run, for reverse-order release. */ + private provisionedResources: ResourceConfig[] = []; + /** Connection details published by this run's resources. */ + private resourceEnv: Record = {}; getAgentVersion(): string { return AGENT_VERSION; @@ -136,7 +151,10 @@ class CopilotProcessor implements WorkerProcessor { this.workspacePath = createFreshWorkspace(); await log("info", "Fresh workspace created", { workspacePath: this.workspacePath }); - // Purge orphan containers from previous runs (crash recovery) + // Purge orphan containers from previous runs (crash recovery). + // + // This must stay BEFORE resource setup: a resource that publishes a fixed + // port cannot start if a container from an earlier run is still holding it. if (KubedockClient.isEnabled()) { this.kubedock = new KubedockClient(); try { @@ -148,18 +166,100 @@ class CopilotProcessor implements WorkerProcessor { } this.mcpConfigs = options?.mcpServerConfigs ?? []; + this.resourceConfigs = options?.resourceConfigs ?? []; + + // Provision resources before registering MCP servers. This ordering is the + // whole point of the feature: registration opens a live connection to the + // server and throws if it is unreachable, so anything the run needs to talk + // to has to exist first. + if (this.resourceConfigs.length > 0) { + await this.preflightDockerSocket(log); + try { + const { values, provisioned } = await runResourceSetups(this.resourceConfigs, { + cwd: this.workspacePath, + env: { ...(process.env.DOCKER_HOST ? { DOCKER_HOST: process.env.DOCKER_HOST } : {}) }, + log: (level, message) => void log(level, message), + }); + this.provisionedResources = provisioned; + this.resourceEnv = values; + await log("info", "Resources provisioned", { + count: provisioned.length, + published: Object.keys(values).sort(), + }); + } catch (err) { + // Unwind whatever already came up before failing the run; a partially + // provisioned environment would otherwise leak into the next run. + await this.releaseResources(log); + throw err; + } + } + if (this.mcpConfigs.length > 0) { if (!McpGatewayClient.isEnabled()) { throw new Error("MCP servers configured but MCP_GATEWAY_URL is not set — cannot proceed without gateway"); } + // Interpolate AFTER secret hydration (done by the queue processor) and + // immediately before registration. Hydration replaces the whole env or + // headers object rather than merging, so substituting any earlier would be + // silently undone. + let configs = this.mcpConfigs; + if (Object.keys(this.resourceEnv).length > 0 || referencedPlaceholders(configs).length > 0) { + configs = interpolateMcpServerConfigs(configs, this.resourceEnv); + this.mcpConfigs = configs; + } this.gateway = new McpGatewayClient(); - await log("info", "Registering MCP servers with gateway", { count: this.mcpConfigs.length, servers: this.mcpConfigs.map((s) => s.name) }); + await log("info", "Registering MCP servers with gateway", { count: configs.length, servers: configs.map((s) => s.name) }); await this.gateway.purgeAll(); - for (const config of this.mcpConfigs) await this.gateway.registerServer(config); + for (const config of configs) await this.gateway.registerServer(config); } } + /** + * Fail early, and legibly, when the Docker socket is unusable. + * + * Without this a container-backed resource fails inside its own script with a + * raw `permission denied ... /var/run/docker.sock`, which reads like a group + * ownership problem even when it is an SELinux label denial — a genuinely + * costly thing to misdiagnose. + */ + private async preflightDockerSocket(log: WorkerLogFn): Promise { + const dockerHost = process.env.DOCKER_HOST; + if (!dockerHost) { + await log("warn", "Resources are configured but DOCKER_HOST is not set — a container-backed resource will fail"); + return; + } + const socketPath = dockerHost.startsWith("unix://") ? dockerHost.slice("unix://".length) : null; + if (!socketPath) return; + try { + await access(socketPath, fsConstants.R_OK | fsConstants.W_OK); + await log("info", "Docker socket is reachable", { dockerHost }); + } catch (err) { + throw new Error( + `Docker socket at ${socketPath} is not usable by this worker (${err instanceof Error ? err.message : String(err)}). ` + + `Resources that start containers cannot run. On a host enforcing SELinux this is usually a label denial rather than ` + + `a GID problem — the container needs security_opt label=disable in addition to the right group_add.`, + ); + } + } + + /** Release provisioned resources in reverse order. Safe to call twice. */ + private async releaseResources(log: WorkerLogFn): Promise { + if (this.provisionedResources.length === 0) return; + const toRelease = this.provisionedResources; + this.provisionedResources = []; + await runResourceTeardowns(toRelease, { + cwd: this.workspacePath ?? process.cwd(), + env: { ...(process.env.DOCKER_HOST ? { DOCKER_HOST: process.env.DOCKER_HOST } : {}) }, + log: (level, message) => void log(level, message), + }); + } + async teardown(log: WorkerLogFn): Promise { + // Release resources BEFORE purging containers. The purge would otherwise + // destroy the very containers a teardown script is about to remove, leaving + // it to fail or silently no-op. + await this.releaseResources(log); + // Clean up containers spawned during this run if (this.kubedock) { try { @@ -286,7 +386,7 @@ class CopilotProcessor implements WorkerProcessor { const result = await runACPSession(message, { command: "copilot", args, - env: buildSubprocessEnv(githubToken, !!devProxy, process.env.NODE_OPTIONS, process.env.MCP_GATEWAY_URL, devProxy?.proxyUrl, caCertBundlePath), + env: buildSubprocessEnv(githubToken, !!devProxy, process.env.NODE_OPTIONS, process.env.MCP_GATEWAY_URL, devProxy?.proxyUrl, caCertBundlePath, this.resourceEnv), cwd: this.workspacePath!, onLog: async (msg) => { lastProtocolEventTime = Date.now(); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 22f6d2ddd..751baba40 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -17,6 +17,7 @@ export * from "./token-manager/index.js"; export * from "./mcp/index.js"; export * from "./skills/index.js"; export * from "./codebases/index.js"; +export * from "./resources/index.js"; export * from "./extensions/index.js"; export * from "./projects/index.js"; export * from "./agent-version.js"; diff --git a/packages/shared/src/resources/index.ts b/packages/shared/src/resources/index.ts new file mode 100644 index 000000000..008648ae9 --- /dev/null +++ b/packages/shared/src/resources/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from "./resource-env.js"; +export * from "./resource-interpolate.js"; +export * from "./resource-runner.js"; diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index 71c2bcca6..e3f9e6595 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -4,6 +4,7 @@ import type { McpServerConfig } from './mcp.js'; import type { SkillConfig } from './skill.js'; import type { ExtensionConfig } from './extension.js'; +import type { ResourceConfig } from './resource.js'; import type { ToolCall } from '../har/types.js'; // Re-export ToolCall so consumers can import from types @@ -473,6 +474,10 @@ export interface WorkerProcessorOptions { * per-project skill revisions (by ref) hit the right project's copy. */ projectId?: string; mcpServerConfigs?: McpServerConfig[]; // Resolved MCP server configurations + /** Resolved resources to provision before the agent starts and release after + * it finishes. Their setup phases publish connection details that are + * interpolated into MCP server config and merged into the agent's env. */ + resourceConfigs?: ResourceConfig[]; skillConfigs?: SkillConfig[]; // Resolved skill configurations for prompt injection extensionConfigs?: ExtensionConfig[]; // Resolved VS Code extension configurations for runtime installation /** Current iteration number (1-based) for multi-turn runs. Used by the From 333c1ac3580e518dac4eb1c6a11cd7501c979143 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 14:59:11 -0700 Subject: [PATCH 06/52] feat(shared): resolve a run's resources and pass them to the worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the worker-side client that turns a resource spec into the `ResourceConfig` a worker needs, and threads resolved resources through the queue processor into `processor.setup()`. Resolution happens in the queue processor rather than inside the worker so that a missing or deleted resource fails the run before any provisioning work starts, instead of halfway through a setup phase that has already created containers. A spec may be a slug, `slug@rN`, or a revision id. The request stores `resourceRevisionIds` — the *resolved* ids — rather than the specs, so a run stays explainable after the resource is edited and its latest revision moves on. This mirrors how `codebaseRevisionId` is pinned from a codebase spec. Order is preserved through resolution because it is meaningful: setup runs in reference order and teardown in reverse, so that dependants unwind before the things they depend on. 960 shared tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- packages/shared/src/queue/queue-processor.ts | 24 +++- packages/shared/src/resources/index.ts | 1 + .../shared/src/resources/resource-client.ts | 108 ++++++++++++++++++ packages/shared/src/schemas/request.ts | 5 + packages/shared/src/types/types.ts | 3 + 5 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 packages/shared/src/resources/resource-client.ts diff --git a/packages/shared/src/queue/queue-processor.ts b/packages/shared/src/queue/queue-processor.ts index ed8f64ca7..6455d8641 100644 --- a/packages/shared/src/queue/queue-processor.ts +++ b/packages/shared/src/queue/queue-processor.ts @@ -37,6 +37,8 @@ import { PromptClient } from "../task-prompts/prompt-client.js"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { CodebaseClient } from "../codebases/codebase-client.js"; +import { ResourceClient } from "../resources/resource-client.js"; +import type { ResourceConfig } from "../types/resource.js"; import { seedCodebaseToWorkspace } from "../codebases/codebase-seeder.js"; /** @@ -435,7 +437,22 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor e.version ? `${e.id}@${e.version}` : e.id).join(", ")}`); } - await this.processMultiTurn(requestDoc, message, heartbeat, log, startedAt, mcpServerConfigs, skillConfigs, extensionConfigs); + // Resolve resource revisions to configs via API. Resolved here rather than + // inside the worker so a failure to find a resource fails the run before any + // setup work happens. + let resourceConfigs: ResourceConfig[] | undefined; + if (requestDoc.resourceRevisionIds && requestDoc.resourceRevisionIds.length > 0) { + const apiBaseUrl = (this.config as QueueProcessorConfig).apiBaseUrl; + if (!apiBaseUrl) { + throw new Error("Resources requested but SCOPE_MT_API_URL is not configured"); + } + const resourceClient = new ResourceClient(apiBaseUrl); + await log("info", `Resolving ${requestDoc.resourceRevisionIds.length} resource(s)`, { resources: requestDoc.resourceRevisionIds }); + resourceConfigs = await resourceClient.resolveResources(requestDoc.projectId, requestDoc.resourceRevisionIds); + await log("info", `Resolved resources: ${resourceConfigs.map(r => r.ref).join(", ")}`); + } + + await this.processMultiTurn(requestDoc, message, heartbeat, log, startedAt, mcpServerConfigs, skillConfigs, extensionConfigs, resourceConfigs); } /** @@ -627,7 +644,8 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor { const requestId = requestDoc._id; // Resolve the runId for blob paths. New requests always have run._id; @@ -687,7 +705,7 @@ export class CodingAgentQueueProcessor extends BaseQueueProcessor 0) { try { diff --git a/packages/shared/src/resources/index.ts b/packages/shared/src/resources/index.ts index 008648ae9..0b6136f39 100644 --- a/packages/shared/src/resources/index.ts +++ b/packages/shared/src/resources/index.ts @@ -4,3 +4,4 @@ export * from "./resource-env.js"; export * from "./resource-interpolate.js"; export * from "./resource-runner.js"; +export * from "./resource-client.js"; diff --git a/packages/shared/src/resources/resource-client.ts b/packages/shared/src/resources/resource-client.ts new file mode 100644 index 000000000..b16ee5495 --- /dev/null +++ b/packages/shared/src/resources/resource-client.ts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + ResourceConfig, + ResourceDocument, + ResourceRevisionDocument, +} from "../types/resource.js"; + +/** + * Client for resolving resource specs via the Scope REST API. + * + * Used by queue processors at message-processing time to turn the specs stored + * on a RequestDocument into the `ResourceConfig` objects a worker needs in order + * to run the lifecycle phases. + * + * A spec is one of: + * - `"github-simulator"` — the resource's latest revision at resolution time + * - `"github-simulator@r2"` — an explicit revision + * - a revision id + * + * Mirrors how a codebase spec is resolved. Note that the *request* should store + * the resolved revision id rather than the spec, so a run stays explainable after + * the resource moves on; this client is what produces that resolution. + */ +export class ResourceClient { + private readonly apiUrl: string; + + constructor(apiUrl: string) { + this.apiUrl = apiUrl.replace(/\/+$/, ""); + } + + private async getJson(path: string, projectId: string): Promise { + const sep = path.includes("?") ? "&" : "?"; + const url = `${this.apiUrl}${path}${sep}projectId=${encodeURIComponent(projectId)}`; + const res = await fetch(url); + if (res.status === 404) return null; + if (!res.ok) { + throw new Error(`[ResourceClient] GET ${url} failed: ${res.status} ${res.statusText}`); + } + return (await res.json()) as T; + } + + /** + * Resolve one spec to a concrete revision. + * + * @throws Error if the resource or revision does not exist. A missing resource + * must fail the run rather than silently producing an environment without it. + */ + async resolveResource(projectId: string, spec: string): Promise { + const trimmed = spec.trim(); + if (trimmed === "") throw new Error("[ResourceClient] empty resource spec"); + + const at = trimmed.lastIndexOf("@r"); + const slug = at > 0 ? trimmed.slice(0, at) : trimmed; + const revisionNumber = at > 0 ? Number(trimmed.slice(at + 2)) : undefined; + + if (at > 0 && (!Number.isInteger(revisionNumber) || revisionNumber! < 1)) { + throw new Error(`[ResourceClient] invalid revision in resource spec '${spec}'`); + } + + // A bare spec may be a slug or a revision id; try the revision id first only + // when it cannot be a slug@rN form. + const revision = + revisionNumber !== undefined + ? await this.getJson( + `/api/v1/resources/${encodeURIComponent(slug)}/revisions/${revisionNumber}`, + projectId, + ) + : ((await this.getJson( + `/api/v1/resources/revisions/${encodeURIComponent(trimmed)}`, + projectId, + )) ?? + (await this.getJson( + `/api/v1/resources/${encodeURIComponent(trimmed)}/revisions/latest`, + projectId, + ))); + + if (!revision) { + throw new Error(`[ResourceClient] resource '${spec}' not found via API`); + } + + const resource = await this.getJson( + `/api/v1/resources/${encodeURIComponent(revision.resourceId)}`, + projectId, + ); + + return { + ref: revision.ref, + resourceId: revision.resourceId, + revisionId: revision._id, + slug: revision.slug, + name: resource?.name ?? revision.slug, + setup: revision.setup, + ...(revision.teardown ? { teardown: revision.teardown } : {}), + exports: revision.exports ?? [], + }; + } + + /** Resolve every spec, in order. Order is preserved because it determines setup order. */ + async resolveResources(projectId: string, specs: string[]): Promise { + const configs: ResourceConfig[] = []; + for (const spec of specs) { + configs.push(await this.resolveResource(projectId, spec)); + } + return configs; + } +} diff --git a/packages/shared/src/schemas/request.ts b/packages/shared/src/schemas/request.ts index cea87ee06..474f29b16 100644 --- a/packages/shared/src/schemas/request.ts +++ b/packages/shared/src/schemas/request.ts @@ -117,6 +117,11 @@ export const CreateRequestInputSchema = z mcpServers: z.array(z.string()).optional(), skillRevisions: z.array(z.string()).optional(), codebaseRevisionId: z.string().optional(), + /** Resource specs (slug, `slug@rN`, or revision id) to provision for this + * run, in setup order. Resolved at submit time and shared by every + * variation in a grouped submission, so each profile gets an identical + * environment. */ + resources: z.array(z.string()).optional(), extensions: z.array(z.string()).optional(), profileId: z.string().optional(), profileVariations: z.array(z.string()).optional(), diff --git a/packages/shared/src/types/types.ts b/packages/shared/src/types/types.ts index e3f9e6595..eb48812c1 100644 --- a/packages/shared/src/types/types.ts +++ b/packages/shared/src/types/types.ts @@ -300,6 +300,9 @@ export interface RequestDocument { mcpServers?: string[]; // MCP server slugs selected for this run skillRevisions?: string[]; // Skill revision refs (e.g. "vercel-labs/agent-skills/my-skill@a1b2c3d") codebaseRevisionId?: string; // FK → CodebaseRevisionDocument._id — seeds the workspace before the agent starts + /** FK → ResourceRevisionDocument._id, in setup order. Pinned at submit time so + * the run stays reproducible after the resource is edited. */ + resourceRevisionIds?: string[]; extensions?: string[]; // VS Code extension IDs selected for this run (e.g. "ms-python.python") agentVersion?: string; // Agent software version prefix (e.g. "copilot-0.0.415") — FK → AgentVersion.agentVersion profileId?: string; // FK → ProfileDocument._id (the profile lineage) From 09c810f644a6a1f158e8f1ca582f1892767a4317 Mon Sep 17 00:00:00 2001 From: Cedric Vidal Date: Sun, 13 Sep 2026 15:14:30 -0700 Subject: [PATCH 07/52] Add resource catalog revisions Introduce the resource store, resolver, API routes, CLI commands, and indexes so lifecycle resources get the same mutable identity plus immutable revision history as codebases. Enforce scoped duplicate checks in the API because Cosmos can downgrade unique indexes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 507f8ebd-cc32-489c-afca-8941c5f8dba1 --- .../openapi-snapshot.test.ts.snap | 668 ++++++++++++++++++ apps/api/src/index.ts | 32 +- apps/api/src/route-context.ts | 10 + apps/api/src/routes/resources.test.ts | 216 ++++++ apps/api/src/routes/resources.ts | 378 ++++++++++ apps/cli/src/commands/resource.ts | 350 +++++++++ apps/cli/src/index.ts | 2 + docs/architecture/app-design.md | 10 +- docs/architecture/db.md | 6 +- docs/architecture/resources.md | 48 ++ .../migrations/029-create-resource-indexes.ts | 51 ++ .../db-migrations/src/required-migrations.ts | 1 + packages/shared/src/resources/index.ts | 4 + .../src/resources/resource-resolver.test.ts | 84 +++ .../shared/src/resources/resource-resolver.ts | 138 ++++ .../src/resources/resource-revision-id.ts | 42 ++ .../resources/resource-revision-store.test.ts | 211 ++++++ .../src/resources/resource-revision-store.ts | 119 ++++ .../shared/src/resources/resource-store.ts | 138 ++++ website/src/openapi/scope-openapi.json | 2 +- 20 files changed, 2503 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/routes/resources.test.ts create mode 100644 apps/api/src/routes/resources.ts create mode 100644 apps/cli/src/commands/resource.ts create mode 100644 docs/architecture/resources.md create mode 100644 packages/db-migrations/src/migrations/029-create-resource-indexes.ts create mode 100644 packages/shared/src/resources/resource-resolver.test.ts create mode 100644 packages/shared/src/resources/resource-resolver.ts create mode 100644 packages/shared/src/resources/resource-revision-id.ts create mode 100644 packages/shared/src/resources/resource-revision-store.test.ts create mode 100644 packages/shared/src/resources/resource-revision-store.ts create mode 100644 packages/shared/src/resources/resource-store.ts diff --git a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap index 3dd189089..3ca0a1d13 100644 --- a/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap +++ b/apps/api/src/__snapshots__/openapi-snapshot.test.ts.snap @@ -950,6 +950,12 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` "reasoningEffort": { "type": "string", }, + "resources": { + "items": { + "type": "string", + }, + "type": "array", + }, "scenario": { "$ref": "#/components/schemas/Scenario", }, @@ -965,6 +971,66 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], "type": "object", }, + "CreateResourceInput": { + "properties": { + "creator": { + "type": "string", + }, + "description": { + "type": "string", + }, + "exports": { + "items": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string", + }, + "type": "array", + }, + "name": { + "minLength": 1, + "type": "string", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "slug": { + "minLength": 1, + "type": "string", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "name", + "setup", + ], + "type": "object", + }, + "CreateResourceRevisionInput": { + "properties": { + "creator": { + "type": "string", + }, + "exports": { + "items": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string", + }, + "type": "array", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "setup", + ], + "type": "object", + }, "CreateSkillInput": { "properties": { "description": { @@ -2670,6 +2736,139 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, + "ResourceResponse": { + "properties": { + "_id": { + "type": "string", + }, + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "creator": { + "type": "string", + }, + "deletedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "description": { + "type": "string", + }, + "latestRevisionId": { + "type": "string", + }, + "latestRevisionNumber": { + "type": "number", + }, + "name": { + "type": "string", + }, + "projectId": { + "type": "string", + }, + "revisionCounter": { + "type": "number", + }, + "slug": { + "type": "string", + }, + "updatedAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + }, + "required": [ + "_id", + "slug", + "name", + "revisionCounter", + "createdAt", + "projectId", + ], + "type": "object", + }, + "ResourceRevisionResponse": { + "properties": { + "_id": { + "type": "string", + }, + "contentSha256": { + "type": "string", + }, + "createdAt": { + "format": "date-time", + "type": [ + "string", + "null", + ], + }, + "creator": { + "type": "string", + }, + "deduplicated": { + "type": "boolean", + }, + "exports": { + "items": { + "type": "string", + }, + "type": "array", + }, + "projectId": { + "type": "string", + }, + "ref": { + "type": "string", + }, + "resourceId": { + "type": "string", + }, + "revisionNumber": { + "type": "number", + }, + "setup": { + "$ref": "#/components/schemas/ResourceScript", + }, + "slug": { + "type": "string", + }, + "teardown": { + "$ref": "#/components/schemas/ResourceScript", + }, + }, + "required": [ + "_id", + "resourceId", + "slug", + "revisionNumber", + "ref", + "setup", + "exports", + "contentSha256", + "createdAt", + "projectId", + ], + "type": "object", + }, + "ResourceScript": { + "properties": { + "sh": { + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + }, "RunFacetBucket": { "properties": { "count": { @@ -3549,6 +3748,18 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` }, "type": "object", }, + "UpdateResourceInput": { + "properties": { + "description": { + "type": "string", + }, + "name": { + "minLength": 1, + "type": "string", + }, + }, + "type": "object", + }, "ValidateKeyInput": { "properties": { "token": { @@ -10048,6 +10259,463 @@ exports[`OpenAPI spec snapshot > matches the committed snapshot 1`] = ` ], }, }, + "/api/v1/resources": { + "get": { + "parameters": [ + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ResourceResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + }, + "summary": "List all resources", + "tags": [ + "Resources", + ], + }, + "post": { + "parameters": [ + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResourceInput", + }, + }, + }, + }, + "responses": { + "201": { + "description": "Success", + }, + "400": { + "description": "Invalid input", + }, + "409": { + "description": "Resource slug or revision ref already exists in this project", + }, + }, + "summary": "Create a resource and its first revision", + "tags": [ + "Resources", + ], + }, + }, + "/api/v1/resources/revisions/{id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Revision not found", + }, + }, + "summary": "Get resource revision by id", + "tags": [ + "Resource Revisions", + ], + }, + }, + "/api/v1/resources/{id}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "204": { + "description": "Success", + }, + "404": { + "description": "Resource not found", + }, + }, + "summary": "Delete a resource", + "tags": [ + "Resources", + ], + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Resource not found", + }, + }, + "summary": "Get a resource", + "tags": [ + "Resources", + ], + }, + "patch": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateResourceInput", + }, + }, + }, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Resource not found", + }, + }, + "summary": "Update a resource", + "tags": [ + "Resources", + ], + }, + }, + "/api/v1/resources/{id}/revisions": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + "type": "array", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Resource not found", + }, + }, + "summary": "List resource revisions", + "tags": [ + "Resource Revisions", + ], + }, + "post": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateResourceRevisionInput", + }, + }, + }, + }, + "responses": { + "201": { + "description": "Success", + }, + "404": { + "description": "Resource not found", + }, + "409": { + "description": "Resource revision ref already exists in this project", + }, + }, + "summary": "Create a resource revision", + "tags": [ + "Resource Revisions", + ], + }, + }, + "/api/v1/resources/{id}/revisions/latest": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Revision not found", + }, + }, + "summary": "Get the latest resource revision", + "tags": [ + "Resource Revisions", + ], + }, + }, + "/api/v1/resources/{id}/revisions/{revisionNumber}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "path", + "name": "revisionNumber", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "in": "query", + "name": "projectId", + "required": true, + "schema": { + "description": "Project scope. Required on scoped list and root-create operations; requests without a resolvable project are rejected with 400. There is no default project.", + "example": "00000000-0000-0000-0000-000000000000", + "minLength": 1, + "type": "string", + }, + }, + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResourceRevisionResponse", + }, + }, + }, + "description": "Success", + }, + "404": { + "description": "Revision not found", + }, + }, + "summary": "Get a resource revision by resource and revision number", + "tags": [ + "Resource Revisions", + ], + }, + }, "/api/v1/runs/upload": { "post": { "parameters": [ diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 1bb32ef04..6113873b7 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -9,9 +9,9 @@ import { DefaultAzureCredential } from "@azure/identity"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import dotenv from "dotenv"; -import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore } from "shared"; +import { TaskPromptStore, SkillRevisionStore, SkillResolver, CodebaseStore, CodebaseRevisionStore, CodebaseResolver, ResourceStore, ResourceRevisionStore, ResourceResolver, McpSecretClient, McpSecretUnavailableError, BlobStorage, RedisHeartbeatStore, ProjectStore } from "shared"; import { initTelemetry } from "telemetry"; -import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore } from "shared"; +import type { TaskPromptDocument, SkillDocument, SkillRevisionDocument, CodebaseDocument, CodebaseRevisionDocument, ResourceDocument, ResourceRevisionDocument, ProfileDocument, ProfileVersionDocument, ProjectDocument, HeartbeatStore } from "shared"; import { acquireGitHubPublicApiToken } from "./github-api-token.js"; import { generateOpenAPIDocument, registry } from "./openapi/index.js"; import swaggerUi from "swagger-ui-express"; @@ -36,6 +36,7 @@ import { registerModelsRoutes } from "./routes/models.js"; import { registerMcpServersRoutes } from "./routes/mcp-servers.js"; import { registerSkillsRoutes } from "./routes/skills.js"; import { registerCodebasesRoutes } from "./routes/codebases.js"; +import { registerResourcesRoutes } from "./routes/resources.js"; import { registerExtensionsRoutes } from "./routes/extensions.js"; import { registerInsightsRoutes } from "./routes/insights.js"; import { registerSecretsRoutes } from "./routes/secrets.js"; @@ -114,6 +115,11 @@ let codebaseRevisionCollection: Collection; let codebaseStore: CodebaseStore; let codebaseRevisionStore: CodebaseRevisionStore; let codebaseResolver: CodebaseResolver; +let resourceCollection: Collection; +let resourceRevisionCollection: Collection; +let resourceStore: ResourceStore; +let resourceRevisionStore: ResourceRevisionStore; +let resourceResolver: ResourceResolver; let blobStorage: BlobStorage; let heartbeatStore: HeartbeatStore; let reportQueueClient: QueueClient; @@ -159,6 +165,12 @@ async function initializeClients(): Promise { tokenProvider: acquireGitHubPublicApiToken, }); + resourceCollection = db.collection("resources"); + resourceRevisionCollection = db.collection("resource-revisions"); + resourceStore = new ResourceStore(resourceCollection); + resourceRevisionStore = new ResourceRevisionStore(resourceRevisionCollection, resourceStore); + resourceResolver = new ResourceResolver(); + // Note: Collection indexes are managed by db-migrations (see 002-create-indexes.ts). // Run `pnpm migrate:up` to apply pending migrations. @@ -250,6 +262,11 @@ const routeCtx: RouteContext = { get codebaseStore() { return codebaseStore; }, get codebaseRevisionStore() { return codebaseRevisionStore; }, get codebaseResolver() { return codebaseResolver; }, + get resourceCollection() { return resourceCollection; }, + get resourceRevisionCollection() { return resourceRevisionCollection; }, + get resourceStore() { return resourceStore; }, + get resourceRevisionStore() { return resourceRevisionStore; }, + get resourceResolver() { return resourceResolver; }, get projectStore() { return projectStore; }, get reportQueueClient() { return reportQueueClient; }, get blobStorage() { return blobStorage; }, @@ -284,6 +301,7 @@ registerModelsRoutes(routeCtx); registerMcpServersRoutes(routeCtx); registerSkillsRoutes(routeCtx); registerCodebasesRoutes(routeCtx); +registerResourcesRoutes(routeCtx); registerExtensionsRoutes(routeCtx); registerInsightsRoutes(routeCtx); registerFeatureFlagRoutes(routeCtx); @@ -348,6 +366,11 @@ export interface TestDependencies { codebaseStore?: CodebaseStore; codebaseRevisionStore?: CodebaseRevisionStore; codebaseResolver?: CodebaseResolver; + resourceCollection?: Collection; + resourceRevisionCollection?: Collection; + resourceStore?: ResourceStore; + resourceRevisionStore?: ResourceRevisionStore; + resourceResolver?: ResourceResolver; reportQueueClient?: QueueClient; blobStorage?: BlobStorage; } @@ -379,6 +402,11 @@ export function _injectTestDependencies(deps: TestDependencies): void { if (deps.codebaseStore) codebaseStore = deps.codebaseStore; if (deps.codebaseRevisionStore) codebaseRevisionStore = deps.codebaseRevisionStore; if (deps.codebaseResolver) codebaseResolver = deps.codebaseResolver; + if (deps.resourceCollection) resourceCollection = deps.resourceCollection; + if (deps.resourceRevisionCollection) resourceRevisionCollection = deps.resourceRevisionCollection; + if (deps.resourceStore) resourceStore = deps.resourceStore; + if (deps.resourceRevisionStore) resourceRevisionStore = deps.resourceRevisionStore; + if (deps.resourceResolver) resourceResolver = deps.resourceResolver; if (deps.reportQueueClient) reportQueueClient = deps.reportQueueClient; if (deps.blobStorage) blobStorage = deps.blobStorage; } diff --git a/apps/api/src/route-context.ts b/apps/api/src/route-context.ts index 7d74ab122..b36258d2e 100644 --- a/apps/api/src/route-context.ts +++ b/apps/api/src/route-context.ts @@ -18,8 +18,13 @@ import type { CodebaseStore, CodebaseRevisionStore, CodebaseResolver, + ResourceStore, + ResourceRevisionStore, + ResourceResolver, CodebaseDocument, CodebaseRevisionDocument, + ResourceDocument, + ResourceRevisionDocument, McpSecretClient, ProfileDocument, ProfileVersionDocument, @@ -100,6 +105,8 @@ export interface RouteContext { profileVersionCollection: Collection; codebaseCollection: Collection; codebaseRevisionCollection: Collection; + resourceCollection: Collection; + resourceRevisionCollection: Collection; // Services taskPromptStore: TaskPromptStore; @@ -108,6 +115,9 @@ export interface RouteContext { codebaseStore: CodebaseStore; codebaseRevisionStore: CodebaseRevisionStore; codebaseResolver: CodebaseResolver; + resourceStore: ResourceStore; + resourceRevisionStore: ResourceRevisionStore; + resourceResolver: ResourceResolver; projectStore: ProjectStore; // Token Manager client (null when TOKEN_MANAGER_URL not set) diff --git a/apps/api/src/routes/resources.test.ts b/apps/api/src/routes/resources.test.ts new file mode 100644 index 000000000..1ace97b24 --- /dev/null +++ b/apps/api/src/routes/resources.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import express from "express"; +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; +import request from "supertest"; +import { describe, expect, it } from "vitest"; +import type { Collection } from "mongodb"; +import { + ResourceResolver, + ResourceRevisionStore, + ResourceStore, + type ResourceDocument, + type ResourceRevisionDocument, +} from "shared"; +import type { RouteContext } from "../route-context.js"; +import { registerResourcesRoutes } from "./resources.js"; + +type UnknownRecord = Record; + +type Update = { + $set?: Partial; + $inc?: Partial>; +}; + +function matches(doc: T, filter: UnknownRecord): boolean { + for (const [key, condition] of Object.entries(filter)) { + if (key === "$or" && Array.isArray(condition)) { + if (!condition.some((sub) => matches(doc, sub as UnknownRecord))) return false; + continue; + } + const value = doc[key]; + if (typeof condition === "object" && condition !== null && "$exists" in condition) { + const exists = value !== undefined; + if ((condition as { $exists: boolean }).$exists !== exists) return false; + continue; + } + if (typeof condition === "object" && condition !== null && "$lt" in condition) { + if (typeof value !== "number") return false; + if (!(value < (condition as { $lt: number }).$lt)) return false; + continue; + } + if (value !== condition) return false; + } + return true; +} + +function fakeCollection(seed: T[] = []) { + const docs = seed.map((doc) => ({ ...doc })); + return { + _docs: () => docs, + async findOne(filter: UnknownRecord) { + return docs.find((doc) => matches(doc as unknown as UnknownRecord, filter)) ?? null; + }, + find(filter: UnknownRecord) { + let result = docs.filter((doc) => matches(doc as unknown as UnknownRecord, filter)); + return { + sort(sortSpec: UnknownRecord) { + if (sortSpec.createdAt === -1) { + result = [...result].sort((a, b) => { + const aTime = a["createdAt" as keyof T] instanceof Date ? (a["createdAt" as keyof T] as Date).getTime() : 0; + const bTime = b["createdAt" as keyof T] instanceof Date ? (b["createdAt" as keyof T] as Date).getTime() : 0; + return bTime - aTime; + }); + } + if (sortSpec.revisionNumber === -1) { + result = [...result].sort((a, b) => Number(b["revisionNumber" as keyof T] ?? 0) - Number(a["revisionNumber" as keyof T] ?? 0)); + } + return this; + }, + limit(limitValue: number) { + result = result.slice(0, limitValue); + return this; + }, + async toArray() { + return result; + }, + }; + }, + async insertOne(doc: T) { + docs.push({ ...doc }); + return { insertedId: doc._id }; + }, + async updateOne(filter: UnknownRecord, update: Update) { + const doc = docs.find((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (!doc) return { matchedCount: 0, modifiedCount: 0 }; + if (update.$inc) { + for (const [key, amount] of Object.entries(update.$inc)) { + const current = Number(doc[key as keyof T] ?? 0); + (doc as unknown as Record)[key] = current + Number(amount); + } + } + if (update.$set) Object.assign(doc, update.$set); + return { matchedCount: 1, modifiedCount: 1 }; + }, + async findOneAndUpdate(filter: UnknownRecord, update: Update) { + const doc = docs.find((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (!doc) return null; + if (update.$inc) { + for (const [key, amount] of Object.entries(update.$inc)) { + const current = Number(doc[key as keyof T] ?? 0); + (doc as unknown as Record)[key] = current + Number(amount); + } + } + if (update.$set) Object.assign(doc, update.$set); + return doc; + }, + async updateMany(filter: UnknownRecord, update: Update) { + let modifiedCount = 0; + for (const doc of docs) { + if (matches(doc as unknown as UnknownRecord, filter)) { + if (update.$set) Object.assign(doc, update.$set); + modifiedCount += 1; + } + } + return { matchedCount: modifiedCount, modifiedCount }; + }, + async deleteOne(filter: UnknownRecord) { + const index = docs.findIndex((candidate) => matches(candidate as unknown as UnknownRecord, filter)); + if (index === -1) return { deletedCount: 0 }; + docs.splice(index, 1); + return { deletedCount: 1 }; + }, + async deleteMany(filter: UnknownRecord) { + const before = docs.length; + for (let index = docs.length - 1; index >= 0; index -= 1) { + if (matches(docs[index] as unknown as UnknownRecord, filter)) docs.splice(index, 1); + } + return { deletedCount: before - docs.length }; + }, + }; +} + +function buildApp() { + const app = express(); + app.use(express.json()); + const resourceCollection = fakeCollection(); + const revisionCollection = fakeCollection(); + const resourceStore = new ResourceStore(resourceCollection as unknown as Collection); + const resourceRevisionStore = new ResourceRevisionStore( + revisionCollection as unknown as Collection, + resourceStore + ); + const ctx = { + app, + registry: new OpenAPIRegistry(), + resourceStore, + resourceRevisionStore, + resourceResolver: new ResourceResolver(), + } as unknown as RouteContext; + registerResourcesRoutes(ctx); + app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(500).json({ error: err.message }); + }); + return { app, resourceCollection, revisionCollection }; +} + +const createBody = { + name: "GitHub Simulator", + slug: "github-simulator", + setup: { sh: "echo URL=http://localhost >> $SCOPE_SETUP_ENV" }, + teardown: { sh: "echo teardown" }, + exports: ["URL"], +}; + +describe("resource routes", () => { + it("rejects duplicate slugs within a project but allows the same slug in another project", async () => { + const { app, resourceCollection } = buildApp(); + + const first = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + const duplicate = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + const otherProject = await request(app).post("/api/v1/resources?projectId=proj-b").send(createBody); + + expect(first.status).toBe(201); + expect(duplicate.status).toBe(409); + expect(duplicate.body.error).toMatch(/already exists/); + expect(otherProject.status).toBe(201); + expect(resourceCollection._docs()).toHaveLength(2); + expect(new Set(resourceCollection._docs().map((doc) => doc.projectId))).toEqual(new Set(["proj-a", "proj-b"])); + }); + + it("creates changed bodies as new immutable revisions without editing older revisions", async () => { + const { app } = buildApp(); + + const created = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + expect(created.status).toBe(201); + const firstRevision = created.body.firstRevision as ResourceRevisionDocument; + + const second = await request(app) + .post("/api/v1/resources/github-simulator/revisions?projectId=proj-a") + .send({ setup: { sh: "echo URL=http://changed >> $SCOPE_SETUP_ENV" }, exports: ["URL"] }); + expect(second.status).toBe(201); + expect(second.body.revisionNumber).toBe(2); + + const fetchedFirst = await request(app).get("/api/v1/resources/github-simulator/revisions/1?projectId=proj-a"); + expect(fetchedFirst.status).toBe(200); + expect(fetchedFirst.body._id).toBe(firstRevision._id); + expect(fetchedFirst.body.setup.sh).toBe(createBody.setup.sh); + expect(fetchedFirst.body.revisionNumber).toBe(1); + }); + + it("does not expose mutation routes for immutable revisions", async () => { + const { app } = buildApp(); + const created = await request(app).post("/api/v1/resources?projectId=proj-a").send(createBody); + expect(created.status).toBe(201); + const revisionId = (created.body.firstRevision as ResourceRevisionDocument)._id; + + const patch = await request(app).patch(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`).send({ setup: { sh: "x" } }); + const put = await request(app).put(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`).send({ setup: { sh: "x" } }); + const del = await request(app).delete(`/api/v1/resources/revisions/${revisionId}?projectId=proj-a`); + + expect(patch.status).toBe(404); + expect(put.status).toBe(404); + expect(del.status).toBe(404); + }); +}); diff --git a/apps/api/src/routes/resources.ts b/apps/api/src/routes/resources.ts new file mode 100644 index 000000000..dcdc6f529 --- /dev/null +++ b/apps/api/src/routes/resources.ts @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { z } from "zod"; +import { + buildResourceRevisionRef, + CreateResourceInputSchema, + CreateResourceRevisionInputSchema, + ResourceResponseSchema, + ResourceRevisionResponseSchema, + slugifyResourceName, + UpdateResourceInputSchema, + type ResourceDocument, +} from "shared"; +import { apiRoute } from "../openapi/api-route.js"; +import type { RouteContext } from "../route-context.js"; +import { ProjectIdQuerySchema, getQueryProjectId } from "../utils/project-scope.js"; + +async function resolveResource( + ctx: RouteContext, + projectId: string, + idOrSlug: string +): Promise { + const byId = await ctx.resourceStore.get(idOrSlug); + if (byId) return byId.projectId === projectId ? byId : null; + return ctx.resourceStore.getBySlug(projectId, idOrSlug); +} + +async function hasResourceSlug(ctx: RouteContext, projectId: string, slug: string): Promise { + return (await ctx.resourceStore.getBySlug(projectId, slug, { includeDeleted: true })) !== null; +} + +async function hasRevisionRef(ctx: RouteContext, projectId: string, ref: string): Promise { + return (await ctx.resourceRevisionStore.getByRef(projectId, ref)) !== null; +} + +export function registerResourcesRoutes(ctx: RouteContext): void { + // =================================================================== + // Resources API + // =================================================================== + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources", + tags: ["Resources"], + summary: "List all resources", + query: ProjectIdQuerySchema, + response: z.array(ResourceResponseSchema), + handler: async (req, res, next) => { + try { + const resources = await ctx.resourceStore.list({ projectId: getQueryProjectId(req) }); + res.json(resources.map((resource) => ({ ...resource, id: resource._id }))); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/resources", + tags: ["Resources"], + summary: "Create a resource and its first revision", + query: ProjectIdQuerySchema, + body: CreateResourceInputSchema, + response: ResourceResponseSchema, + rawResponse: true, + successStatus: 201, + errorResponses: { + 400: { description: "Invalid input" }, + 409: { description: "Resource slug or revision ref already exists in this project" }, + }, + handler: async (req, res, next) => { + try { + const projectId = getQueryProjectId(req); + const { name, slug: requestedSlug, description, setup, teardown, exports: exportedNames, creator } = req.body; + const slug = slugifyResourceName(requestedSlug ?? name); + if (!slug) { + res.status(400).json({ error: "Could not derive a valid slug from the resource name" }); + return; + } + + // Cosmos DB degrades the project-scoped unique index to non-unique, so + // the route must enforce same-project uniqueness before insert. A + // soft-deleted resource still reserves its slug so old refs stay stable. + if (await hasResourceSlug(ctx, projectId, slug)) { + res.status(409).json({ error: `A resource with slug '${slug}' already exists in this project.` }); + return; + } + + const firstRef = buildResourceRevisionRef(slug, 1); + if (await hasRevisionRef(ctx, projectId, firstRef)) { + res.status(409).json({ error: `A resource revision with ref '${firstRef}' already exists in this project.` }); + return; + } + + const resource = await ctx.resourceStore.create({ + projectId, + name, + slug, + ...(description ? { description } : {}), + ...(creator ? { creator } : {}), + }); + + try { + const result = await ctx.resourceResolver.createRevision( + resource, + { + setup, + ...(teardown ? { teardown } : {}), + ...(exportedNames ? { exports: exportedNames } : {}), + ...(creator ? { creator } : {}), + }, + ctx.resourceRevisionStore + ); + const fresh = (await ctx.resourceStore.get(resource._id)) ?? resource; + res.status(201).json({ ...fresh, id: fresh._id, firstRevision: result.revision }); + } catch (revisionError) { + await ctx.resourceRevisionStore.deleteByResource(resource._id); + await ctx.resourceStore.hardDelete(resource._id); + const message = revisionError instanceof Error ? revisionError.message : String(revisionError); + res.status(400).json({ error: `Failed to create the first resource revision: ${message}` }); + } + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Get a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceResponseSchema, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + res.json({ ...resource, id: resource._id }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "patch", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Update a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + body: UpdateResourceInputSchema, + response: ResourceResponseSchema, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const updated = await ctx.resourceStore.update(resource._id, req.body); + if (!updated) { + res.status(404).json({ error: "Resource not found" }); + return; + } + res.json({ ...updated, id: updated._id }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "delete", + path: "/api/v1/resources/:id", + tags: ["Resources"], + summary: "Delete a resource", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: z.any(), + rawResponse: true, + successStatus: 204, + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const ok = await ctx.resourceStore.softDelete(resource._id); + if (!ok) { + res.status(404).json({ error: "Resource not found" }); + return; + } + await ctx.resourceRevisionStore.softDeleteByResource(resource._id); + res.status(204).send(); + } catch (error) { + next(error); + } + }, + }); + + // =================================================================== + // Resource Revisions API + // =================================================================== + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions", + tags: ["Resource Revisions"], + summary: "List resource revisions", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema.merge(z.object({ limit: z.string().optional() })), + response: z.array(ResourceRevisionResponseSchema), + errorResponses: { 404: { description: "Resource not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const limitStr = req.query.limit as string | undefined; + const limit = Math.min(Math.max(parseInt(limitStr ?? "50", 10), 1), 200); + const revisions = await ctx.resourceRevisionStore.listByResource(resource._id, { limit }); + res.json(revisions); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "post", + path: "/api/v1/resources/:id/revisions", + tags: ["Resource Revisions"], + summary: "Create a resource revision", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + body: CreateResourceRevisionInputSchema, + response: ResourceRevisionResponseSchema, + rawResponse: true, + successStatus: 201, + errorResponses: { + 404: { description: "Resource not found" }, + 409: { description: "Resource revision ref already exists in this project" }, + }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + + // Application-level scoped ref guard for Cosmos DB, where the unique + // index degrades to non-unique. The atomic counter remains authoritative; + // this catches pre-existing/corrupt collisions before the insert path. + const nextRef = buildResourceRevisionRef(resource.slug, resource.revisionCounter + 1); + if (await hasRevisionRef(ctx, resource.projectId, nextRef)) { + res.status(409).json({ error: `A resource revision with ref '${nextRef}' already exists in this project.` }); + return; + } + + const { setup, teardown, exports: exportedNames, creator } = req.body; + const result = await ctx.resourceResolver.createRevision( + resource, + { + setup, + ...(teardown ? { teardown } : {}), + ...(exportedNames ? { exports: exportedNames } : {}), + ...(creator ? { creator } : {}), + }, + ctx.resourceRevisionStore + ); + res + .status(result.deduplicated ? 200 : 201) + .json({ ...result.revision, deduplicated: result.deduplicated }); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions/latest", + tags: ["Resource Revisions"], + summary: "Get the latest resource revision", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const revision = await ctx.resourceRevisionStore.getLatest(resource._id); + if (!revision) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/:id/revisions/:revisionNumber", + tags: ["Resource Revisions"], + summary: "Get a resource revision by resource and revision number", + params: z.object({ id: z.string(), revisionNumber: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const revisionNumber = Number(req.params.revisionNumber); + if (!Number.isInteger(revisionNumber) || revisionNumber < 1) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + const resource = await resolveResource(ctx, getQueryProjectId(req), req.params.id); + if (!resource) { + res.status(404).json({ error: "Resource not found" }); + return; + } + const revision = await ctx.resourceRevisionStore.getByNumber(resource._id, revisionNumber); + if (!revision) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); + + apiRoute(ctx.app, ctx.registry, { + method: "get", + path: "/api/v1/resources/revisions/:id", + tags: ["Resource Revisions"], + summary: "Get resource revision by id", + params: z.object({ id: z.string() }), + query: ProjectIdQuerySchema, + response: ResourceRevisionResponseSchema, + errorResponses: { 404: { description: "Revision not found" } }, + handler: async (req, res, next) => { + try { + const revision = await ctx.resourceRevisionStore.get(req.params.id); + if (!revision || revision.projectId !== getQueryProjectId(req)) { + res.status(404).json({ error: "Resource revision not found" }); + return; + } + res.json(revision); + } catch (error) { + next(error); + } + }, + }); +} diff --git a/apps/cli/src/commands/resource.ts b/apps/cli/src/commands/resource.ts new file mode 100644 index 000000000..689e12e3b --- /dev/null +++ b/apps/cli/src/commands/resource.ts @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { existsSync, readFileSync } from "fs"; +import { resolve } from "path"; +import { Command } from "commander"; +import type { ResourceDocument, ResourceRevisionDocument, ResourceScript } from "shared"; +import { configureHelp } from "../utils/helpFormatter.js"; +import { formatData, isMachineReadable } from "../utils/formatters.js"; +import type { DisplayField, OutputFormat } from "../utils/types.js"; +import { getDefaultApiUrl, withOutputOption, withProjectOption } from "../utils/shared.js"; +import { requireProjectId } from "../utils/config.js"; +import { apiFetch } from "../utils/api-client.js"; +import { errorText, label, successText, value, warnBanner } from "../utils/style.js"; + +type JsonDate = string | Date; +type ResourceApiDocument = Omit & { + id?: string; + createdAt: JsonDate; + updatedAt?: JsonDate; + deletedAt?: JsonDate; + firstRevision?: ResourceRevisionApiDocument; +}; +type ResourceRevisionApiDocument = Omit & { + id?: string; + createdAt: JsonDate; + deletedAt?: JsonDate; + deduplicated?: boolean; +}; + +interface ApiErrorBody { + error?: string; +} + +interface LifecycleOptions { + setupSh?: string; + setupFile?: string; + teardownSh?: string; + teardownFile?: string; + exports?: string[]; + creator?: string; +} + +function resourceId(resource: ResourceApiDocument): string { + return resource.id ?? resource._id; +} + +async function readError(response: Response): Promise { + const error = (await response.json().catch((): ApiErrorBody => ({ error: response.statusText }))) as ApiErrorBody; + return error.error ?? JSON.stringify(error); +} + +async function fetchJson(baseUrl: string, path: string, projectId: string, init?: RequestInit): Promise { + const response = await apiFetch(baseUrl, path, { ...init, projectId }); + if (!response.ok) throw new Error(await readError(response)); + return (await response.json()) as T; +} + +function readTextFile(path: string): string { + const fullPath = resolve(path); + if (!existsSync(fullPath)) { + throw new Error(`Path not found: ${fullPath}`); + } + return readFileSync(fullPath, "utf8"); +} + +function buildLifecycleBody(options: LifecycleOptions): { + setup: ResourceScript; + teardown?: ResourceScript; + exports?: string[]; + creator?: string; +} { + if (options.setupSh && options.setupFile) { + throw new Error("Use either --setup-sh or --setup-file, not both"); + } + if (options.teardownSh && options.teardownFile) { + throw new Error("Use either --teardown-sh or --teardown-file, not both"); + } + const setupBody = options.setupSh ?? (options.setupFile ? readTextFile(options.setupFile) : undefined); + if (!setupBody) { + throw new Error("Provide --setup-sh or --setup-file"); + } + const teardownBody = options.teardownSh ?? (options.teardownFile ? readTextFile(options.teardownFile) : undefined); + return { + setup: { sh: setupBody }, + ...(teardownBody ? { teardown: { sh: teardownBody } } : {}), + ...(options.exports && options.exports.length > 0 ? { exports: options.exports } : {}), + ...(options.creator ? { creator: options.creator } : {}), + }; +} + +function resourceFields(): DisplayField[] { + return [ + { key: "slug", label: "Slug", tableFormatter: (resource) => value(resource.slug) }, + { key: "name", label: "Name" }, + { key: "latestRevisionNumber", label: "Latest", formatter: (resource) => resource.latestRevisionNumber?.toString() ?? "—" }, + { key: "latestRevisionId", label: "Latest Revision", formatter: (resource) => resource.latestRevisionId ?? "—" }, + { key: "createdAt", label: "Created", formatter: (resource) => new Date(resource.createdAt).toLocaleString() }, + ]; +} + +function revisionFields(): DisplayField[] { + return [ + { key: "ref", label: "Ref", tableFormatter: (revision) => value(revision.ref) }, + { key: "revisionNumber", label: "Revision", formatter: (revision) => revision.revisionNumber.toString() }, + { key: "exports", label: "Exports", formatter: (revision) => revision.exports.join(", ") || "—" }, + { key: "contentSha256", label: "Content SHA", formatter: (revision) => revision.contentSha256.substring(0, 12) }, + { key: "createdAt", label: "Created", formatter: (revision) => new Date(revision.createdAt).toLocaleString() }, + ]; +} + +function parseRevisionRef(spec: string): { slug: string; revisionNumber: number } | null { + const match = /^(.+)@r(\d+)$/.exec(spec); + if (!match) return null; + return { slug: match[1], revisionNumber: Number(match[2]) }; +} + +async function resolveResource(baseUrl: string, projectId: string, idOrSlug: string): Promise { + return fetchJson(baseUrl, `/resources/${encodeURIComponent(idOrSlug)}`, projectId); +} + +function addLifecycleOptions(command: Command): Command { + return command + .option("--setup-sh