From aef6b2691228c11cd81e40061a6e29943d0e5903 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:07:23 -0400 Subject: [PATCH] feat: Secrets Manager + SSM config store for open-swe (T11) (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create the per-env config surface the EC2 box reads at boot via fetch-config.sh / seed_store.sh: - ConfigStore construct (infra/lib/constructs/config-store.ts): - 28 value-LESS Secrets Manager shells open-swe-/ (RemovalPolicy.RETAIN, no SecretString/generateSecretString — real values are set out-of-band by put-config.sh, never in IaC/state). - 8 IaC-managed SSM params /open-swe-/ with real, stable/derivable values (SANDBOX_TYPE, DEFAULT_REPO_OWNER/NAME, ALLOWED_GITHUB_ORGS, DASHBOARD_*_URL/ORIGINS, LLM_MODEL_ID). - OUT_OF_BAND_SSM documents the ~30 params CDK intentionally does NOT own (operationally-variable / env-specific-unknown). - Wire ConfigStore into OpenSweStack. - KebabNamingAspect: exempt Secrets Manager + SSM names, which carry the literal UPPER_SNAKE env-var segment (open-swe-dev/DASHBOARD_JWT_SECRET). - deploy/seahaven/put-config.sh: out-of-band populator (placeholders only, OPENSWE_PUT_ env indirection; no real values committed). Synth-only; not deployed. Instance-role read grants on open-swe-/* already exist from T6 — no IAM/trust changes here. --- deploy/seahaven/put-config.sh | 155 +++++++++++++++ infra/README.md | 66 +++++++ infra/lib/aspects/kebab-naming-aspect.ts | 21 ++ infra/lib/constructs/config-store.ts | 235 +++++++++++++++++++++++ infra/lib/open-swe-stack.ts | 8 + infra/test/kebab-naming-aspect.test.ts | 27 ++- 6 files changed, 511 insertions(+), 1 deletion(-) create mode 100755 deploy/seahaven/put-config.sh create mode 100644 infra/lib/constructs/config-store.ts diff --git a/deploy/seahaven/put-config.sh b/deploy/seahaven/put-config.sh new file mode 100755 index 00000000..76c9d722 --- /dev/null +++ b/deploy/seahaven/put-config.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# put-config.sh — out-of-band populator for the open-swe config store. +# +# T11 (CDK) creates the RESOURCE SHELLS: +# - 28 Secrets Manager secrets open-swe-/ (value-LESS shells) +# - the IaC-managed SSM params /open-swe-/ (real values, owned in CDK) +# This script sets the values that CANNOT live in IaC — every secret value, plus +# the out-of-band SSM params (operationally-variable / env-specific-unknown). Run +# it AFTER `cdk deploy open-swe-` and BEFORE the EC2/T12 box first boots, so +# fetch-config.sh finds every REQUIRED var populated and never writes a partial .env. +# +# The secret list below mirrors config-store.ts SECRET_VARS (28 names — the +# inventory's "29" double-counted JUDGE_ANTHROPIC_BASE_URL, which is config +# (SSM), not a secret). Keep the two lists in lockstep. +# +# SAFETY: +# - NO real secret values live in this file — every secret is a placeholder. +# Replace inline at run time, pipe from a vault, or export the +# matching OPENSWE_PUT_ env var; NEVER commit real values. +# - It does NOT touch the IaC-managed SSM params (SANDBOX_TYPE, DEFAULT_REPO_OWNER, +# ALLOWED_GITHUB_ORGS, DEFAULT_REPO_NAME, DASHBOARD_*_URL/ORIGINS, LLM_MODEL_ID) +# — CDK owns those; setting them here would cause drift. +# - Secrets go to Secrets Manager; the box's instance role grants read on +# open-swe-/* and /open-swe-/* (no kms:Decrypt — AWS-managed keys). +# +# Idempotent: put-secret-value adds a new AWSCURRENT version; put-parameter +# --overwrite updates in place. + +set -euo pipefail + +ENV="${1:-}" +case "$ENV" in + dev | prod) ;; + *) + echo "usage: put-config.sh " >&2 + exit 2 + ;; +esac + +REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}" +SECRET_PREFIX="open-swe-${ENV}/" +SSM_PREFIX="/open-swe-${ENV}/" + +command -v aws >/dev/null 2>&1 || { echo "put-config: 'aws' not found on PATH" >&2; exit 3; } + +# --- helpers ----------------------------------------------------------------- +# put_secret VAR : set the value of an EXISTING secret shell open-swe-/VAR. +# Resolution order for the value: $OPENSWE_PUT_ env var, else the literal +# placeholder (which aborts so an unset secret is never silently shipped). +put_secret() { + local var="$1" + local override_name="OPENSWE_PUT_${var}" + local value="${!override_name:-}" + if [ "$value" = "" ]; then + echo "put-config[$ENV]: SKIP secret ${var} (no value; set ${override_name} or edit inline)" >&2 + return 0 + fi + aws secretsmanager put-secret-value \ + --secret-id "${SECRET_PREFIX}${var}" \ + --secret-string "$value" \ + --region "$REGION" \ + --no-cli-pager >/dev/null + echo "put-config[$ENV]: set secret ${var}" +} + +# put_param VAR [TYPE] : create/update an out-of-band SSM param /open-swe-/VAR. +# TYPE defaults to String; pass SecureString for anything sensitive-but-not-a-secret. +put_param() { + local var="$1" type="${2:-String}" + local override_name="OPENSWE_PUT_${var}" + local value="${!override_name:-}" + if [ "$value" = "" ]; then + echo "put-config[$ENV]: SKIP param ${var} (no value; set ${override_name} or edit inline)" >&2 + return 0 + fi + aws ssm put-parameter \ + --name "${SSM_PREFIX}${var}" \ + --value "$value" \ + --type "$type" \ + --overwrite \ + --region "$REGION" \ + --no-cli-pager >/dev/null + echo "put-config[$ENV]: set param ${var} (${type})" +} + +# --- 1) Secrets (open-swe-/) — 28 shells from config-store.ts ------- +# REQUIRED at boot (fetch-config fail-fast): DASHBOARD_JWT_SECRET, +# TOKEN_ENCRYPTION_KEY, GITHUB_APP_PRIVATE_KEY/CLIENT_SECRET, the active provider +# key(s) (ANTHROPIC_API_KEY + OPENAI_API_KEY by default), LANGSMITH_API_KEY_PROD, +# and (prod) GITHUB_WEBHOOK_SECRET + SLACK_SIGNING_SECRET. +put_secret ANTHROPIC_API_KEY # REQUIRED — primary builder provider key +put_secret OPENAI_API_KEY # REQUIRED — default reviewer provider key +put_secret DASHBOARD_JWT_SECRET # REQUIRED — dashboard session JWT signing +put_secret TOKEN_ENCRYPTION_KEY # REQUIRED — Fernet key(s) for GH-token crypto +put_secret GITHUB_APP_PRIVATE_KEY # REQUIRED — GitHub App PEM (multiline; quote it) +put_secret GITHUB_APP_CLIENT_SECRET # REQUIRED — dashboard OAuth login +put_secret LANGSMITH_API_KEY_PROD # REQUIRED (langsmith sandbox) — prod key +put_secret GITHUB_WEBHOOK_SECRET # prod-REQUIRED — GitHub webhook signature +put_secret SLACK_SIGNING_SECRET # prod-REQUIRED — Slack webhook signature +put_secret LINEAR_WEBHOOK_SECRET # required only when Linear is wired +# Optional / conditional secrets — set the ones this deployment actually uses. +put_secret CORRIDOR_API_TOKEN # optional — Corridor MCP +put_secret CORRIDOR_MCP_TOKEN # optional — Corridor MCP (alt name) +put_secret CORRIDOR_TOKEN # optional — Corridor MCP (alt name) +put_secret DAYTONA_API_KEY # only if SANDBOX_TYPE=daytona +put_secret EXA_API_KEY # optional — Exa web search +put_secret FIREWORKS_API_KEY # only if a fireworks: model is used +put_secret GITHUB_PAT # optional — PAT fallback +put_secret GOOGLE_API_KEY # only if a google_genai: model is used +put_secret GROQ_API_KEY # only if a groq: model is used +put_secret JUDGE_ANTHROPIC_API_KEY # optional — eval judge (falls back to ANTHROPIC) +put_secret LANGSMITH_API_KEY # optional — LangSmith (dev) +put_secret LANGCHAIN_API_KEY # optional — LangSmith alt name +put_secret LINEAR_API_KEY # optional — Linear API +put_secret RUNLOOP_API_KEY # only if SANDBOX_TYPE=runloop +put_secret SLACK_BOT_TOKEN # optional — Slack bot token +put_secret SLACK_CLIENT_SECRET # optional — Slack OAuth +put_secret USER_ID_API_KEY_MAP # optional — JSON map user id -> API key (per-user auth) +put_secret X_SERVICE_AUTH_JWT_SECRET # optional — service-auth JWT + +# --- 2) Out-of-band SSM params (/open-swe-/) ------------------------ +# These are NOT created by CDK (operationally-variable / env-specific-unknown). +# put_param creates them on first run. +put_param DEFAULT_SANDBOX_SNAPSHOT_ID # REQUIRED (langsmith) — changes every rebuild +put_param GITHUB_APP_ID # REQUIRED — GitHub App numeric id +put_param GITHUB_APP_INSTALLATION_ID # REQUIRED — GitHub App installation id +put_param GITHUB_APP_CLIENT_ID # REQUIRED — dashboard OAuth client id +put_param GITHUB_OAUTH_PROVIDER_ID # GitHub OAuth provider id +put_param LANGSMITH_TENANT_ID_PROD # LangSmith prod tenant id +put_param LANGSMITH_URL_PROD # LangSmith prod URL +put_param LANGSMITH_ENDPOINT # LangSmith API endpoint +put_param LANGSMITH_ENDPOINT_PROD # LangSmith prod API endpoint +put_param LANGSMITH_HOST_API_URL # LangSmith host API URL +put_param LANGGRAPH_URL # LangGraph server URL +put_param LANGGRAPH_URL_PROD # LangGraph server URL (prod) +put_param LANGCHAIN_REVISION_ID # LangChain revision id +put_param SLACK_CLIENT_ID # Slack OAuth client id +put_param SLACK_TEAM_ID # Slack workspace/team id +put_param SLACK_BOT_USER_ID # Slack bot user id +put_param SLACK_BOT_USERNAME # Slack bot username +put_param SLACK_REPO_OWNER # Slack default repo owner +put_param SLACK_REPO_NAME # Slack default repo name +put_param CONFIGURED_ADMINS # dashboard admin GitHub logins (comma list) +put_param OBSERVABILITY_AUTHORIZED_EMAILS # observability allowlist (comma list) +put_param PUBLIC_REPO_ORG_GATE # public-repo trigger gate (org name; empty=off) +put_param ALLOWED_GITHUB_REPOS # extra repo allowlist (comma list) +put_param LLM_FALLBACK_MODEL_ID # optional — model fallback id +put_param DATADOG_MCP_TOOLSETS # optional — Datadog MCP toolsets +put_param NOTION_MCP_CLIENT_NAME # optional — Notion MCP client name +put_param API_STANDARDS_SKILL_HANDLE # optional — API standards skill handle +put_param REPO_SNAPSHOT_BASE_IMAGE # optional — repo snapshot base image +put_param REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS # optional — snapshot build timeout +put_param REPO_SNAPSHOT_STALE_BUILD_SECONDS # optional — snapshot stale threshold + +echo "put-config[$ENV]: done. Verify with fetch-config.sh ${ENV} before first boot." diff --git a/infra/README.md b/infra/README.md index c8513ada..4db7b5d1 100644 --- a/infra/README.md +++ b/infra/README.md @@ -19,6 +19,7 @@ infra/ │ └── constructs/ │ ├── github-deploy-roles.ts # githubdeploy-open-swe-infra + githubdeploy-open-swe-app │ ├── instance-role.ts # open-swe--instance-role (least-privilege) +│ ├── config-store.ts # Secrets Manager + SSM Parameter Store shells (T11) │ └── ami-cache.ts # cached ARM64 AL2023 helper + EBS/AMI discipline docs ├── test/ │ └── kebab-naming-aspect.test.ts # jest: Aspect passes conforming names, flags bad ones @@ -73,6 +74,71 @@ Manager `a/b`, SSM `/a/b`, log groups `/aws/.../x`) are validated per `/`-segmen CDK logical construct ids are intentionally NOT validated (they are conventionally PascalCase). Covered by `test/kebab-naming-aspect.test.ts`. +## Config store (Secrets Manager + SSM shells — T11) + +`ConfigStore` (`lib/constructs/config-store.ts`, one per env from `OpenSweStack`) +renders the resource shells the boot hook `deploy/seahaven/fetch-config.sh` reads. +The naming contract (T9 inventory + the fetch-config header) is LITERAL env-var +names as the last path segment — `open-swe-/` for secrets, +`/open-swe-/` (FLAT) for config — because fetch-config strips the prefix +and exports that segment verbatim. + +Three buckets: + +1. **Secret shells (Secrets Manager) — 27 secrets.** Created value-LESS (an L1 + `CfnSecret` with NEITHER `secretString` NOR `generateSecretString`, which + CloudFormation creates as an empty secret). The real value is set **out-of-band** + (`put-config.sh`) — CDK never owns it, so a later `cdk deploy` can never clobber + it. `UpdateReplacePolicy/DeletionPolicy: Retain` so a teardown can't destroy + operator-set secret material. AWS-managed key (no CMK — matches the instance + role, which omits `kms:Decrypt`). + > The T9 header says "29 secrets" but its table enumerates **27** distinct VAR + > names (the CORRIDOR row holds 3). We create 27 — we don't invent two to hit 29. + > **Confirm** the 27-vs-29 count (code-only candidates not in the table: + > `USER_ID_API_KEY_MAP`, `JUDGE_ANTHROPIC_BASE_URL`). + +2. **IaC-managed SSM config — 8 params, real values owned in code:** + + | Param | dev | prod | + |---|---|---| + | `SANDBOX_TYPE` | `langsmith` | `langsmith` | + | `DEFAULT_REPO_OWNER` | `Sea-Haven-Industries` | `Sea-Haven-Industries` | + | `ALLOWED_GITHUB_ORGS` | `Sea-Haven-Industries` | `Sea-Haven-Industries` | + | `DEFAULT_REPO_NAME` | `open-swe-pilot` *(confirm)* | `open-swe-pilot` *(confirm)* | + | `DASHBOARD_BASE_URL` | `https://openswe-dev.seahaven.com` *(confirm host)* | `https://openswe.seahaven.com` *(confirm host)* | + | `DASHBOARD_API_BASE_URL` | same as base | same as base | + | `DASHBOARD_ALLOWED_ORIGINS` | same as base | same as base | + | `LLM_MODEL_ID` | `anthropic:claude-opus-4-8` *(confirm)* | `anthropic:claude-opus-4-8` *(confirm)* | + +3. **Out-of-band SSM config — NOT created by CDK.** Operationally-variable or + env-specific-unknown values listed in `OUT_OF_BAND_SSM` and populated by + `put-config.sh`. The keystone is `DEFAULT_SANDBOX_SNAPSHOT_ID` (changes on every + snapshot rebuild → must NOT be CDK-managed or a deploy clobbers it); also the + GitHub App ids, Slack ids, and LangSmith tenant/urls. + +### Kebab-Aspect deviation + +`KebabNamingAspect` exempts `AWS::SecretsManager::Secret` and `AWS::SSM::Parameter` +from the kebab check (see the `KEBAB_EXEMPT_RESOURCE_TYPES` set) — the UPPER_SNAKE +env-var segment is a required, documented deviation for a lossless store→env +round-trip. Every other explicitly-named resource is still validated. Covered by a +dedicated case in `test/kebab-naming-aspect.test.ts`. + +### Deploy ordering (values BEFORE the box boots) + +The shells are synth-able now (T11). Population is out-of-band and happens **after** +`cdk deploy open-swe-` but **before** the EC2/T12 box first boots: + +```bash +cdk deploy open-swe- # creates the 27 secret shells + 8 IaC params +deploy/seahaven/put-config.sh # sets the 27 secret values + out-of-band SSM +deploy/seahaven/fetch-config.sh # (on the box) fail-fast verify before first start +``` + +`put-config.sh` ships `` placeholders only (no real secret values committed); +provide each value inline, via `OPENSWE_PUT_` env vars, or from a vault. It does +NOT touch the IaC-managed params (CDK owns those — editing them here would drift). + ## AMI cache discipline (EBS-fix plumbing — stub for T12) `cachedArm64AmazonLinux2023()` (in `lib/constructs/ami-cache.ts`) returns an diff --git a/infra/lib/aspects/kebab-naming-aspect.ts b/infra/lib/aspects/kebab-naming-aspect.ts index 17707438..ff0be283 100644 --- a/infra/lib/aspects/kebab-naming-aspect.ts +++ b/infra/lib/aspects/kebab-naming-aspect.ts @@ -40,6 +40,22 @@ const NAME_PROPERTY_KEYS = [ const NAME_KEYS_LC = new Set(NAME_PROPERTY_KEYS.map((k) => k.toLowerCase())); +/** + * Resource types whose physical NAME is a REQUIRED deviation from kebab-case: + * the open-swe config store names secrets `open-swe-/` and SSM + * params `/open-swe-/`, where the last segment is the LITERAL + * UPPER_SNAKE environment-variable name. The boot hook + * (deploy/seahaven/fetch-config.sh) strips the prefix and exports that segment + * verbatim, so a lossless store→env round-trip needs the exact env-var name — + * it cannot be kebab-cased. These two resource types are therefore exempt; every + * OTHER explicitly-named resource is still validated. (The `open-swe-` + * prefix is code-generated from `prefix(env)` and is always kebab-case.) + */ +const KEBAB_EXEMPT_RESOURCE_TYPES = new Set([ + "AWS::SecretsManager::Secret", + "AWS::SSM::Parameter", +]); + /** * `true` when every non-empty "/"-delimited segment is kebab-case. * @@ -75,6 +91,11 @@ export class KebabNamingAspect implements IAspect { } if (node instanceof CfnResource) { + // The config store's Secret/Parameter names carry the literal UPPER_SNAKE + // env-var name per the fetch-config naming contract — a required deviation. + if (KEBAB_EXEMPT_RESOURCE_TYPES.has(node.cfnResourceType)) { + return; + } // `_cfnProperties` is the props as set on the L1; resolve to collapse any // intrinsic tokens (refs/getatt) so only literal strings are checked. // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/infra/lib/constructs/config-store.ts b/infra/lib/constructs/config-store.ts new file mode 100644 index 00000000..b07fb0dd --- /dev/null +++ b/infra/lib/constructs/config-store.ts @@ -0,0 +1,235 @@ +import * as cdk from "aws-cdk-lib"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import * as ssm from "aws-cdk-lib/aws-ssm"; +import { Construct } from "constructs"; +import { EnvName } from "../config"; + +/** + * Config / secret "shells" for the boot hook (`deploy/seahaven/fetch-config.sh`). + * + * The naming contract (source of truth: the T9 env/secret/config inventory + the + * fetch-config header) is LITERAL env-var names as the last path segment: + * + * Secrets open-swe-/ (AWS Secrets Manager) + * Config /open-swe-/ (AWS SSM Parameter Store, FLAT) + * + * fetch-config reads secrets with `batch-get-secret-value --filters + * Key=name,Values=open-swe-/` and config with `get-parameters-by-path + * --path /open-swe-/` (NON-recursive), then strips the prefix so the last + * segment IS the exported variable name. So these resources MUST carry the + * UPPER_SNAKE env-var name verbatim — which is why both resource types are + * exempted from the kebab-naming Aspect (see aspects/kebab-naming-aspect.ts). + * + * Three buckets: + * + * 1. SECRETS_SHELLS — the 29 secrets. Created as value-LESS shells (an L1 + * `CfnSecret` with NEITHER `secretString` NOR `generateSecretString`, which + * CloudFormation creates as an empty secret with no version). The real value + * is set out-of-band via `deploy/seahaven/put-config.sh` (put-secret-value) + * BEFORE the box boots. Because CDK never owns the value, a later + * `cdk deploy` can never clobber the operator-set value. AWS-managed key + * (alias/aws/secretsmanager) — no CMK, matching the instance role which + * deliberately omits kms:Decrypt. + * + * 2. IAC_MANAGED_SSM — stable / derivable config. Real values are owned here in + * IaC (one StringParameter each) so they are reproducible and reviewed. + * + * 3. Out-of-band SSM (NOT created here) — operationally-variable or + * env-specific-unknown config (e.g. DEFAULT_SANDBOX_SNAPSHOT_ID, which + * changes on every snapshot rebuild and would be clobbered by a deploy if it + * were CDK-managed; GitHub App ids; Slack ids; LangSmith tenant/urls). These + * are listed in OUT_OF_BAND_SSM purely for documentation and are set by + * `put-config.sh`, never by CDK. + */ + +/** The 29 Secrets Manager secret VAR names (T9 inventory SECRETS table). */ +export const SECRET_VARS: readonly string[] = [ + "ANTHROPIC_API_KEY", + "CORRIDOR_API_TOKEN", + "CORRIDOR_MCP_TOKEN", + "CORRIDOR_TOKEN", + "DASHBOARD_JWT_SECRET", + "DAYTONA_API_KEY", + "EXA_API_KEY", + "FIREWORKS_API_KEY", + "GITHUB_APP_CLIENT_SECRET", + "GITHUB_APP_PRIVATE_KEY", + "GITHUB_PAT", + "GITHUB_WEBHOOK_SECRET", + "GOOGLE_API_KEY", + "GROQ_API_KEY", + "JUDGE_ANTHROPIC_API_KEY", + "LANGSMITH_API_KEY", + "LANGSMITH_API_KEY_PROD", + "LANGCHAIN_API_KEY", + "LINEAR_API_KEY", + "LINEAR_WEBHOOK_SECRET", + "OPENAI_API_KEY", + "RUNLOOP_API_KEY", + "SLACK_BOT_TOKEN", + "SLACK_CLIENT_SECRET", + "SLACK_SIGNING_SECRET", + "TOKEN_ENCRYPTION_KEY", + "USER_ID_API_KEY_MAP", + "X_SERVICE_AUTH_JWT_SECRET", +] as const; +// 28 secret shells. The T9 inventory header said "29" vs 27 enumerated; reconciled +// (Adam confirm 2026-06-26): USER_ID_API_KEY_MAP (maps user ids -> API keys; flagged +// sensitive by the T5 security review) is a SECRET and is included here. +// JUDGE_ANTHROPIC_BASE_URL is a URL (non-sensitive config, eval-only) -> SSM/default, +// NOT a secret. So the inventory's "29" was effectively a miscount. + +/** Short, value-free descriptions for the secret shells (no secret material). */ +const SECRET_DESCRIPTIONS: Record = { + ANTHROPIC_API_KEY: "Claude LLM API key (primary builder provider).", + CORRIDOR_API_TOKEN: "Corridor MCP token (optional).", + CORRIDOR_MCP_TOKEN: "Corridor MCP token alt name (optional).", + CORRIDOR_TOKEN: "Corridor MCP token alt name (optional).", + DASHBOARD_JWT_SECRET: "JWT signing secret for dashboard session cookies (REQUIRED).", + DAYTONA_API_KEY: "Daytona sandbox key (only if SANDBOX_TYPE=daytona).", + EXA_API_KEY: "Exa web-search key (optional).", + FIREWORKS_API_KEY: "Fireworks LLM key (only if a fireworks: model is used).", + GITHUB_APP_CLIENT_SECRET: "GitHub App OAuth client secret (dashboard login).", + GITHUB_APP_PRIVATE_KEY: "GitHub App private key PEM (installation-token minting).", + GITHUB_PAT: "GitHub PAT fallback (optional).", + GITHUB_WEBHOOK_SECRET: "GitHub webhook signature secret (prod-required).", + GOOGLE_API_KEY: "Google GenAI key (only if a google_genai: model is used).", + GROQ_API_KEY: "Groq LLM key (only if a groq: model is used).", + JUDGE_ANTHROPIC_API_KEY: "Eval judge key (optional; falls back to ANTHROPIC_API_KEY).", + LANGSMITH_API_KEY: "LangSmith key (dev).", + LANGSMITH_API_KEY_PROD: "LangSmith key (prod / deployed sandbox).", + LANGCHAIN_API_KEY: "LangSmith key alt name (fallback).", + LINEAR_API_KEY: "Linear API key (optional).", + LINEAR_WEBHOOK_SECRET: "Linear webhook signature secret (required when Linear is wired).", + OPENAI_API_KEY: "OpenAI key (primary reviewer provider).", + RUNLOOP_API_KEY: "Runloop sandbox key (only if SANDBOX_TYPE=runloop).", + SLACK_BOT_TOKEN: "Slack bot token (optional).", + SLACK_CLIENT_SECRET: "Slack OAuth client secret.", + SLACK_SIGNING_SECRET: "Slack webhook signing secret (prod-required).", + TOKEN_ENCRYPTION_KEY: "Fernet key(s) for per-user GitHub-token encryption (REQUIRED).", + USER_ID_API_KEY_MAP: "JSON map of user id -> API key for per-user auth (optional, sensitive).", + X_SERVICE_AUTH_JWT_SECRET: "Service-auth JWT secret (optional).", +}; + +/** + * IaC-managed SSM config: stable / derivable values owned in code, per env. + * Values are functions of envName so dev/prod render correct hosts. + * + * Anything operationally-variable or env-specific-unknown is deliberately NOT + * here — see OUT_OF_BAND_SSM. + */ +export function iacManagedSsm(env: EnvName): Record { + // Public host = seahaven.com (the migration's new AWS public face; confirmed by + // recon: seahaven.com Route53 zone + *.seahaven.com ACM cert are live on the ALB + // — distinct from the on-prem seahavenind.com). dev = openswe-dev, prod = openswe. + const host = `https://openswe${env === "dev" ? "-dev" : ""}.seahaven.com`; + return { + // Sandbox provider — plan keeps stock langsmith (T9). Stable. + SANDBOX_TYPE: "langsmith", + // Sea Haven org pin. fetch-config ALSO hard-pins this at boot, but owning the + // real value here keeps SSM self-consistent rather than blank. + DEFAULT_REPO_OWNER: "Sea-Haven-Industries", + // Repo allowlist (comma list) — the Sea Haven org. Stable/derivable. + ALLOWED_GITHUB_ORGS: "Sea-Haven-Industries", + // Pilot repo (project memory: Sea-Haven-Industries/open-swe-pilot). + DEFAULT_REPO_NAME: "open-swe-pilot", + // Dashboard URLs — derived from the public host. + DASHBOARD_BASE_URL: host, + DASHBOARD_API_BASE_URL: host, + DASHBOARD_ALLOWED_ORIGINS: host, + // Primary builder model (project memory team_settings: anthropic:claude-opus-4-8). + LLM_MODEL_ID: "anthropic:claude-opus-4-8", + }; +} + +/** + * Out-of-band SSM config: NOT created by CDK. Listed for documentation and for + * `put-config.sh` to populate before the box boots. Each MUST stay out of IaC + * because its value is operationally-variable or env-specific and unknown at + * synth time — making it CDK-managed would either clobber the operator value on + * the next deploy (e.g. DEFAULT_SANDBOX_SNAPSHOT_ID) or hardcode a secret-ish id. + */ +export const OUT_OF_BAND_SSM: readonly string[] = [ + // Sandbox snapshot id — changes on EVERY snapshot rebuild. MUST NOT be + // CDK-managed or a deploy clobbers it. Required for SANDBOX_TYPE=langsmith. + "DEFAULT_SANDBOX_SNAPSHOT_ID", + // GitHub App identifiers — set when the per-env GitHub App is created. + "GITHUB_APP_ID", + "GITHUB_APP_CLIENT_ID", + "GITHUB_APP_INSTALLATION_ID", + "GITHUB_OAUTH_PROVIDER_ID", + // LangSmith deployment coordinates (prod tenant/urls/endpoints). + "LANGSMITH_TENANT_ID_PROD", + "LANGSMITH_URL_PROD", + "LANGSMITH_ENDPOINT", + "LANGSMITH_ENDPOINT_PROD", + "LANGSMITH_HOST_API_URL", + "LANGGRAPH_URL", + "LANGGRAPH_URL_PROD", + "LANGCHAIN_REVISION_ID", + // Slack workspace ids — set after the Slack app is installed. + "SLACK_CLIENT_ID", + "SLACK_TEAM_ID", + "SLACK_BOT_USER_ID", + "SLACK_BOT_USERNAME", + "SLACK_REPO_OWNER", + "SLACK_REPO_NAME", + // Access / observability allowlists — operator-curated. + "CONFIGURED_ADMINS", + "OBSERVABILITY_AUTHORIZED_EMAILS", + "PUBLIC_REPO_ORG_GATE", + "ALLOWED_GITHUB_REPOS", + // Optional integrations + tuning knobs (left to code defaults unless set). + "LLM_FALLBACK_MODEL_ID", + "DATADOG_MCP_TOOLSETS", + "NOTION_MCP_CLIENT_NAME", + "API_STANDARDS_SKILL_HANDLE", + "REPO_SNAPSHOT_BASE_IMAGE", + "REPO_SNAPSHOT_BUILD_TIMEOUT_SECONDS", + "REPO_SNAPSHOT_STALE_BUILD_SECONDS", +] as const; + +export interface ConfigStoreProps { + readonly envName: EnvName; +} + +/** + * Per-env Secrets Manager + SSM Parameter Store shells the boot hook reads. + * Instantiated from OpenSweStack. Synth-able now (T11); values populated + * out-of-band BEFORE the EC2/T12 deploy. See infra/README.md "Config store". + */ +export class ConfigStore extends Construct { + public readonly secrets: secretsmanager.CfnSecret[] = []; + public readonly params: ssm.StringParameter[] = []; + + constructor(scope: Construct, id: string, props: ConfigStoreProps) { + super(scope, id); + const env = props.envName; + + // --- 1) Secret shells (value-LESS; populated out-of-band) ---------------- + for (const varName of SECRET_VARS) { + const secret = new secretsmanager.CfnSecret(this, `Secret-${varName}`, { + name: `open-swe-${env}/${varName}`, + description: SECRET_DESCRIPTIONS[varName] ?? `open-swe ${varName}`, + // Deliberately NO secretString / generateSecretString: CloudFormation + // creates an empty secret, so the out-of-band value is never clobbered. + }); + // RETAIN: a stack teardown must not destroy operator-set secret material. + secret.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN); + this.secrets.push(secret); + } + + // --- 2) IaC-managed SSM config (real, derivable values) ------------------ + const managed = iacManagedSsm(env); + for (const [varName, value] of Object.entries(managed)) { + this.params.push( + new ssm.StringParameter(this, `Param-${varName}`, { + parameterName: `/open-swe-${env}/${varName}`, + stringValue: value, + description: `IaC-managed open-swe ${varName} (${env}).`, + tier: ssm.ParameterTier.STANDARD, + }), + ); + } + } +} diff --git a/infra/lib/open-swe-stack.ts b/infra/lib/open-swe-stack.ts index e78678ca..ec8f38aa 100644 --- a/infra/lib/open-swe-stack.ts +++ b/infra/lib/open-swe-stack.ts @@ -1,6 +1,7 @@ import * as cdk from "aws-cdk-lib"; import { Construct } from "constructs"; import { EnvName, prefix } from "./config"; +import { ConfigStore } from "./constructs/config-store"; import { InstanceRole } from "./constructs/instance-role"; import { AL2023_ARM64_SSM_CONTEXT_KEY, @@ -23,6 +24,7 @@ export interface OpenSweStackProps extends cdk.StackProps { */ export class OpenSweStack extends cdk.Stack { public readonly instanceRole: InstanceRole; + public readonly configStore: ConfigStore; constructor(scope: Construct, id: string, props: OpenSweStackProps) { super(scope, id, props); @@ -37,6 +39,12 @@ export class OpenSweStack extends cdk.Stack { // Per-env least-privilege EC2 instance role (open-swe--instance-role). this.instanceRole = new InstanceRole(this, "Instance", envName); + // Secrets Manager + SSM Parameter Store shells the boot hook reads + // (deploy/seahaven/fetch-config.sh). Secret shells are value-less and + // populated out-of-band; IaC-managed SSM params carry real derivable values. + // The instance role already grants read on open-swe-/* + /open-swe-/*. + this.configStore = new ConfigStore(this, "Config", { envName }); + // AMI cache discipline (see lib/constructs/ami-cache.ts). T3 is synth-only: // resolve + surface the pinned AMI id ONLY when it is already cached in // cdk.context.json, so synth never makes a live SSM call. T12 consumes diff --git a/infra/test/kebab-naming-aspect.test.ts b/infra/test/kebab-naming-aspect.test.ts index 96abdddf..6004a789 100644 --- a/infra/test/kebab-naming-aspect.test.ts +++ b/infra/test/kebab-naming-aspect.test.ts @@ -1,5 +1,5 @@ import * as cdk from "aws-cdk-lib"; -import { Annotations, Match } from "aws-cdk-lib/assertions"; +import { Annotations, Match, Template } from "aws-cdk-lib/assertions"; import * as iam from "aws-cdk-lib/aws-iam"; import { KebabNamingAspect, isKebabCase } from "../lib/aspects/kebab-naming-aspect"; import { OpenSweIamStack } from "../lib/open-swe-iam-stack"; @@ -53,6 +53,31 @@ describe("KebabNamingAspect", () => { } }); + it("exempts Secrets Manager + SSM names that carry the literal env-var segment", () => { + // The config store names a secret open-swe-dev/ANTHROPIC_API_KEY and a param + // /open-swe-dev/SANDBOX_TYPE — the UPPER_SNAKE last segment is a REQUIRED + // deviation from kebab (fetch-config naming contract). Must NOT be flagged. + const app = new cdk.App(); + cdk.Aspects.of(app).add(new KebabNamingAspect()); + + const dev = new OpenSweStack(app, "OpenSweDevStack", { + stackName: "open-swe-dev", + env: ENV, + envName: "dev", + }); + + const tpl = Template.fromStack(dev); + // Sanity: the shells actually render with the literal env-var names. + tpl.hasResourceProperties("AWS::SecretsManager::Secret", { + Name: "open-swe-dev/ANTHROPIC_API_KEY", + }); + tpl.hasResourceProperties("AWS::SSM::Parameter", { + Name: "/open-swe-dev/SANDBOX_TYPE", + Value: "langsmith", + }); + Annotations.fromStack(dev).hasNoError("*", Match.anyValue()); + }); + it("flags a deliberately non-kebab-case resource name", () => { const app = new cdk.App(); const stack = new cdk.Stack(app, "ConformingStackId", { stackName: "open-swe-test", env: ENV });