open-swe/infra/lib/constructs/config-store.ts
Adam Moussa f379fbdaa9
Some checks are pending
Infra CD / Infra CI (pre-deploy) (push) Waiting to run
Infra CD / Deploy open-swe-dev (push) Blocked by required conditions
Infra CD / Deploy open-swe-prod (push) Blocked by required conditions
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
docs: document RETAIN secret-shell orphan gotcha (#52)
RETAIN + a fixed secret name means a failed FIRST create leaves empty
secret shells behind when the stack rolls back. The shells keep the
global `open-swe-<env>/<VAR>` names, so every later create fails with
`AlreadyExists`, and a plain delete-secret keeps the name reserved for
the recovery window rather than freeing it.

Record the trap and the force-delete recovery (only for empty shells)
in the config-store construct and the infra README so the next
teardown/rebuild, secret logical-id change, or new-env stand-up does
not rediscover it the hard way.

Prod's first deploy hit this on 2026-06-29: 28 orphaned shells from an
earlier failed create reserved the names and had to be force-deleted
before the stack would create.
2026-06-29 10:51:49 -04:00

264 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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-<env>/<ENV_VAR_NAME> (AWS Secrets Manager)
* Config /open-swe-<env>/<ENV_VAR_NAME> (AWS SSM Parameter Store, FLAT)
*
* fetch-config reads secrets with `batch-get-secret-value --filters
* Key=name,Values=open-swe-<env>/` and config with `get-parameters-by-path
* --path /open-swe-<env>/` (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<string, string> = {
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<string, string> {
// 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`;
// Repo targeting is PER-ENV: dev drives the disposable sandbox repo in the
// dedicated seahaven-open-swe-dev org (isolates dev-agent activity from the real
// Sea Haven org); prod stays on the Sea Haven org pilot repo. fetch-config's owner
// GUARD honors this value (rejecting only blank / the upstream langchain-ai org).
const repo =
env === "dev"
? { owner: "seahaven-open-swe-dev", name: "openswe-dev-sandbox" }
: { owner: "Sea-Haven-Industries", name: "open-swe-pilot" };
const managed: Record<string, string> = {
// Sandbox provider — plan keeps stock langsmith (T9). Stable.
SANDBOX_TYPE: "langsmith",
DEFAULT_REPO_OWNER: repo.owner,
// Repo-owner allowlist (comma list) — the env's org. The app gates triggers on this.
ALLOWED_GITHUB_ORGS: repo.owner,
DEFAULT_REPO_NAME: repo.name,
// 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",
};
// Dev e2e smoke: seed the owner's user_mapping so an @openswe comment from the
// triggering GitHub login resolves (an unmapped commenter is silently skipped).
// Dev-only — prod seeds its mappings via its own operator config. login:email.
if (env === "dev") {
managed.SEED_USER_MAPPINGS = "amoussa1229:adam@seahavenind.com";
}
return managed;
}
/**
* 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.
//
// GOTCHA — RETAIN + fixed name orphans these shells on a FAILED FIRST
// CREATE. If the stack's initial create fails and rolls back, RETAIN keeps
// the shells instead of deleting them; the stack is then gone but the
// secrets survive, still holding the global `open-swe-<env>/<VAR>` names.
// Every later create then fails with `AlreadyExists` (and a normal
// delete-secret keeps the name reserved for the 7–30 day recovery window,
// so it does NOT clear the deadlock). Recovery: before re-creating the
// stack, force-delete the orphans so the names free immediately, e.g.
// aws secretsmanager list-secrets --filters Key=name,Values=open-swe-<env>/ \
// --query 'SecretList[].Name' --output text | tr '\t' '\n' | while read n; do
// aws secretsmanager delete-secret --secret-id "$n" \
// --force-delete-without-recovery; done
// Only force-delete shells that are EMPTY (no value version) — a populated
// secret holds real operator material. This bites on teardown/rebuild, a
// secret logical-id change/refactor, or standing up a new env — NOT on
// routine updates of an already-created stack. (Hit on prod 2026-06-29.)
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,
}),
);
}
}
}