mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-07 16:19:09 +00:00
Compare commits
31 commits
f6c215fff7
...
3c69dd9de6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c69dd9de6 | ||
|
|
9acf071ae4 | ||
|
|
faae9a685b | ||
|
|
52cc696431 | ||
|
|
f79f49c755 | ||
|
|
2533402f6b | ||
|
|
df96fe49b5 | ||
|
|
485674d941 | ||
|
|
719741f279 | ||
|
|
f3db9f02e3 | ||
|
|
3af0bd5e16 | ||
|
|
3224d7abf4 | ||
|
|
5e30bc6be2 | ||
|
|
082768ff51 | ||
|
|
f2633f9bd0 | ||
|
|
a2877d46ff | ||
|
|
cdcb6625b9 | ||
|
|
cfbdcda9b7 | ||
|
|
5fa132205b | ||
|
|
9e2b215f08 | ||
|
|
2765b65acd | ||
|
|
404b3f6f75 | ||
|
|
70319cac0d | ||
|
|
fcbdfb67aa | ||
|
|
aef6b26912 | ||
|
|
434d0ad80c | ||
|
|
ce9e5fb51b | ||
|
|
92fd886076 | ||
|
|
5a5818f4c2 | ||
|
|
35bc9e22f4 | ||
| cf57ba7aa5 |
67 changed files with 9207 additions and 9647 deletions
59
.github/dependabot.yml
vendored
59
.github/dependabot.yml
vendored
|
|
@ -1,52 +1,45 @@
|
|||
version: 2
|
||||
updates:
|
||||
# Python — uv (open-swe-specific; siblings use pip)
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "weekly"
|
||||
assignees: ["amoussa1229"]
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# JavaScript/TypeScript — CDK (/infra), Playwright (/tests/e2e), dashboard (/ui), root tooling
|
||||
- package-ecosystem: "npm"
|
||||
directories:
|
||||
- "/"
|
||||
- "/infra"
|
||||
- "/tests/e2e"
|
||||
- "/ui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
assignees: ["amoussa1229"]
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# Docker — root Dockerfile
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "weekly"
|
||||
assignees: ["amoussa1229"]
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
interval: "weekly"
|
||||
assignees: ["amoussa1229"]
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
update-types: ["minor", "patch"]
|
||||
|
|
|
|||
101
.github/scripts/check-dev-green.sh
vendored
Executable file
101
.github/scripts/check-dev-green.sh
vendored
Executable file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env bash
|
||||
# Promotion gate: refuse to promote a dev HEAD that is not fully green.
|
||||
#
|
||||
# Reads check-runs on stdin — one
|
||||
# name<US>status<US>conclusion<US>details_url
|
||||
# per line, fields separated by ASCII Unit Separator (0x1F) — so it is unit-testable
|
||||
# WITHOUT GitHub. promote-dev-to-prod.yml pipes the live `gh api .../check-runs`
|
||||
# output in. 0x1F (not TAB) is used deliberately: TAB is IFS-whitespace, so an empty
|
||||
# conclusion (every in_progress check has a null conclusion) would collapse and shift
|
||||
# the columns — which would make the promote run fail to exclude itself. 0x1F is
|
||||
# non-whitespace, so `read` preserves empty fields and the columns stay aligned.
|
||||
#
|
||||
# A dev HEAD is promotable ONLY when BOTH hold:
|
||||
# 1. every present check-run is completed with a passing conclusion
|
||||
# (success/neutral/skipped); any pending/failed/cancelled/timed_out check BLOCKS.
|
||||
# 2. every check named in REQUIRED_CHECKS is present AND concluded "success".
|
||||
# This positive allow-list is what stops a partial-signal promotion — e.g. a
|
||||
# commit pushed with [skip ci] (no CI check-runs) that still carries one
|
||||
# unrelated green check, or a required check silently renamed/dropped. The gate
|
||||
# FAILS SAFE: a missing required check blocks rather than promotes.
|
||||
#
|
||||
# Self-exclusion: the promotion workflow's OWN in-progress check-run is dropped by
|
||||
# EXCLUDE_RUN_ID (its github.run_id, matched in the check-run details_url) — an
|
||||
# unforgeable identity, NOT a mutable check name. A check that merely happens to be
|
||||
# named "promote" can no longer hide a real failing/required check.
|
||||
set -euo pipefail
|
||||
|
||||
EXCLUDE_RUN_ID="${EXCLUDE_RUN_ID:-}"
|
||||
# Name of the promotion workflow's own check-run (its job id). STALE check-runs from
|
||||
# prior FAILED promote runs on the same dev HEAD are dropped in the loop so they don't
|
||||
# self-poison the "every present check must be green" rule. Configurable so a job
|
||||
# rename doesn't silently break the exclusion.
|
||||
PROMOTE_CHECK_NAME="${PROMOTE_CHECK_NAME:-promote}"
|
||||
# Mandatory checks (one per line). Defaults to the CI suite, which runs on
|
||||
# every push to dev (see ci.yml). Keep in sync with those job names; if a name
|
||||
# drifts the gate blocks (fails safe) until the list is updated.
|
||||
REQUIRED_CHECKS="${REQUIRED_CHECKS:-Lint
|
||||
Format check
|
||||
Unit tests
|
||||
Playwright E2E}"
|
||||
|
||||
declare -A GREEN
|
||||
seen=0
|
||||
bad=0
|
||||
while IFS=$'\037' read -r name status conclusion details_url; do
|
||||
[ -n "${name:-}" ] || continue
|
||||
# drop the promotion run's own check-run by run id (never by name).
|
||||
if [ -n "${EXCLUDE_RUN_ID}" ] && \
|
||||
[ "${details_url}" != "${details_url#*/runs/"${EXCLUDE_RUN_ID}"/}" ]; then
|
||||
continue
|
||||
fi
|
||||
# Also drop ANY check-run from the promotion workflow itself — not just THIS run
|
||||
# (above) but STALE ones left by prior FAILED promote runs on the same dev HEAD,
|
||||
# which would otherwise self-poison this gate forever (the failure check-run is
|
||||
# immutable on the commit). Scoped to name==PROMOTE_CHECK_NAME AND a GitHub Actions
|
||||
# run URL (details_url under /actions/runs/) so an EXTERNAL app cannot hide a real
|
||||
# failing check by naming it "promote". The positive REQUIRED_CHECKS allow-list
|
||||
# below is the authoritative signal regardless — "promote" is never a required check.
|
||||
if [ "${name}" = "${PROMOTE_CHECK_NAME}" ] && \
|
||||
[ "${details_url}" != "${details_url#*/actions/runs/}" ]; then
|
||||
continue
|
||||
fi
|
||||
seen=$((seen + 1))
|
||||
if [ "${status}" != "completed" ]; then
|
||||
echo "BLOCK: check '${name}' is '${status}' (not completed)" >&2
|
||||
bad=1
|
||||
continue
|
||||
fi
|
||||
case "${conclusion}" in
|
||||
success)
|
||||
GREEN["${name}"]=1
|
||||
echo "ok: ${name} (success)" ;;
|
||||
neutral | skipped)
|
||||
echo "ok: ${name} (${conclusion})" ;;
|
||||
*)
|
||||
echo "BLOCK: check '${name}' concluded '${conclusion:-<none>}'" >&2
|
||||
bad=1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "${seen}" -eq 0 ]; then
|
||||
echo "BLOCK: no check-runs found for this commit — refusing to promote an unverified dev HEAD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
missing=0
|
||||
while IFS= read -r req; do
|
||||
[ -n "${req}" ] || continue
|
||||
if [ -z "${GREEN[${req}]:-}" ]; then
|
||||
echo "BLOCK: required check '${req}' is missing or not successful on dev HEAD" >&2
|
||||
missing=1
|
||||
fi
|
||||
done <<EOF
|
||||
${REQUIRED_CHECKS}
|
||||
EOF
|
||||
|
||||
if [ "${bad}" -ne 0 ] || [ "${missing}" -ne 0 ]; then
|
||||
echo "BLOCK: dev HEAD is not promotable (see reasons above)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: ${seen} check(s) present, all green; all required checks present + successful."
|
||||
52
.github/scripts/package-artifacts.sh
vendored
Executable file
52
.github/scripts/package-artifacts.sh
vendored
Executable file
|
|
@ -0,0 +1,52 @@
|
|||
#!/usr/bin/env bash
|
||||
# Package the two release artifacts (run from the repo root by build-artifacts.yml):
|
||||
#
|
||||
# spa.tar.gz = CONTENTS of the built SPA dir (ui/.output/public/*), so it extracts
|
||||
# straight into the nginx web root with _shell.html at the root.
|
||||
# app.tar.gz = the Python source the box runs `uv sync` against. `git archive`
|
||||
# gives a clean tree (no node_modules, no .venv, no local cruft);
|
||||
# ui/ is intentionally excluded (it ships as spa.tar.gz).
|
||||
set -euo pipefail
|
||||
|
||||
SPA_DIR="ui/.output/public"
|
||||
[ -f "${SPA_DIR}/_shell.html" ] || {
|
||||
echo "ERROR: SPA build output missing ${SPA_DIR}/_shell.html (did 'bun run build' run?)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "==> spa.tar.gz from ${SPA_DIR}"
|
||||
tar -C "${SPA_DIR}" -czf spa.tar.gz .
|
||||
|
||||
echo "==> app.tar.gz from source (git archive HEAD)"
|
||||
git archive --format=tar.gz -o app.tar.gz HEAD \
|
||||
agent deploy langgraph.json pyproject.toml uv.lock README.md
|
||||
|
||||
# List the archive ONCE into a variable. (`tar -tzf ... | grep -q ...` is unsafe
|
||||
# under `set -o pipefail`: grep -q exits on first match, SIGPIPEs tar -> "write
|
||||
# error" -> the pipeline reports non-zero even though grep succeeded, a false
|
||||
# failure. Listing once avoids the pipe entirely.)
|
||||
APP_LIST="$(tar -tzf app.tar.gz)"
|
||||
|
||||
# Sanity: the box's `uv sync --frozen` needs pyproject.toml + uv.lock at the root,
|
||||
# and the package itself (agent/). Fail loudly here rather than on the box.
|
||||
for required in pyproject.toml uv.lock agent/server.py langgraph.json; do
|
||||
printf '%s\n' "${APP_LIST}" | grep -qx "${required}" || {
|
||||
echo "ERROR: app.tar.gz is missing ${required}" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
# Fail-closed secret guard: the source is git-archived wholesale, so reject the
|
||||
# release if a secret-shaped FILE slipped into the tracked tree (defense in depth
|
||||
# on top of .gitignore — the artifact lands on the box + in S3). Scoped to data
|
||||
# extensions so credential-handling *source* (e.g. team_credentials.py) is not a
|
||||
# false positive.
|
||||
SECRET_RE='(^|/)(\.env(\..+)?|id_rsa|.*\.(pem|key|p12|pfx)|.*(secret|credential|password|token)s?\.(json|ya?ml|txt|env|ini|cfg))$'
|
||||
if printf '%s\n' "${APP_LIST}" | grep -qiE "${SECRET_RE}"; then
|
||||
echo "ERROR: app.tar.gz contains a secret-shaped file — refusing to publish:" >&2
|
||||
printf '%s\n' "${APP_LIST}" | grep -iE "${SECRET_RE}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> artifacts:"
|
||||
ls -la spa.tar.gz app.tar.gz
|
||||
64
.github/scripts/publish-and-deploy.sh
vendored
Executable file
64
.github/scripts/publish-and-deploy.sh
vendored
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
# Publish the packaged artifacts to the env's S3 bucket and roll the box to them.
|
||||
# Run by build-artifacts.yml AFTER aws creds are configured (env: ENV, BUCKET,
|
||||
# DEPLOY_DOC). Each release is stored immutably under releases/<sha>/.
|
||||
#
|
||||
# releases/latest/ (what the box's deploy.sh pulls) is advanced TRANSACTIONALLY:
|
||||
# it is pointed at the new release, the box is rolled, and ONLY on a successful
|
||||
# roll is it kept — a failed roll reverts releases/latest/ to the prior release so
|
||||
# a later box boot / replacement never self-deploys a release that failed to come
|
||||
# up. releases/last-good/ (rollback fallback) is advanced only after success and
|
||||
# means "last release whose deploy.sh brought the service up active" (deploy.sh
|
||||
# gates on `systemctl is-active`), not merely "last uploaded".
|
||||
#
|
||||
# The fire/wait/gate against the box lives in roll-box.sh (shared with rollback.sh);
|
||||
# the deploy is fired by TAG (project=open-swe,env=<env>), exactly what the app
|
||||
# deploy role's tag-scoped ssm:SendCommand allows.
|
||||
set -euo pipefail
|
||||
|
||||
: "${ENV:?}" "${BUCKET:?}" "${DEPLOY_DOC:?}"
|
||||
SHA="${GITHUB_SHA:?}"
|
||||
[ -f app.tar.gz ] && [ -f spa.tar.gz ] || { echo "ERROR: artifacts not built" >&2; exit 1; }
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Re-point releases/latest/ at the release stored under releases/<sha>/, recording
|
||||
# the sha so the pointer is self-describing (used to revert on failure).
|
||||
point_latest() {
|
||||
local s="$1" f
|
||||
for f in app.tar.gz spa.tar.gz; do
|
||||
aws s3 cp "s3://${BUCKET}/releases/${s}/${f}" "s3://${BUCKET}/releases/latest/${f}"
|
||||
done
|
||||
printf '%s\n' "${s}" | aws s3 cp - "s3://${BUCKET}/releases/latest/sha.txt"
|
||||
}
|
||||
|
||||
# Which release does latest point at right now? (empty on the very first deploy.)
|
||||
PREV_SHA="$(aws s3 cp "s3://${BUCKET}/releases/latest/sha.txt" - 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
|
||||
echo "==> upload release ${SHA} to s3://${BUCKET}/releases/${SHA}/"
|
||||
for f in app.tar.gz spa.tar.gz; do
|
||||
aws s3 cp "${f}" "s3://${BUCKET}/releases/${SHA}/${f}"
|
||||
done
|
||||
|
||||
echo "==> point releases/latest/ -> ${SHA} (was ${PREV_SHA:-<none>})"
|
||||
point_latest "${SHA}"
|
||||
|
||||
# Roll the box to releases/latest/. On a non-Success aggregate, roll-box.sh exits
|
||||
# non-zero; revert latest to the prior release so no later boot pulls the bad one.
|
||||
if ! ROLL_COMMENT="release ${SHA}" bash "${HERE}/roll-box.sh"; then
|
||||
if [ -n "${PREV_SHA}" ]; then
|
||||
echo "!! deploy failed — reverting releases/latest/ -> ${PREV_SHA}" >&2
|
||||
point_latest "${PREV_SHA}"
|
||||
else
|
||||
echo "!! deploy failed on the FIRST release — leaving releases/latest/ = ${SHA} (no prior release to revert to)" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Roll succeeded (service came up active) -> this release is now the known-good one.
|
||||
echo "==> mark releases/last-good/ = ${SHA} (rollback fallback target)"
|
||||
for f in app.tar.gz spa.tar.gz; do
|
||||
aws s3 cp "s3://${BUCKET}/releases/${SHA}/${f}" "s3://${BUCKET}/releases/last-good/${f}"
|
||||
done
|
||||
printf '%s\n' "${SHA}" | aws s3 cp - "s3://${BUCKET}/releases/last-good/sha.txt"
|
||||
|
||||
echo "==> ${ENV} rolled to release ${SHA} (now last-good)"
|
||||
59
.github/scripts/roll-box.sh
vendored
Executable file
59
.github/scripts/roll-box.sh
vendored
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env bash
|
||||
# Fire the env's SSM deploy document (tag-targeted) and wait for it to finish,
|
||||
# gating on the AGGREGATE command status. Shared by publish-and-deploy.sh (forward
|
||||
# roll) and rollback.sh (backward roll) so the fire/wait/gate logic lives in ONE
|
||||
# place. Requires ENV + DEPLOY_DOC in the environment and aws creds already set.
|
||||
#
|
||||
# Tag-targeting (project=open-swe,env=<env>) is exactly what the app deploy role's
|
||||
# tag-scoped ssm:SendCommand allows — no ec2:DescribeInstances, no instance id.
|
||||
set -euo pipefail
|
||||
: "${ENV:?}" "${DEPLOY_DOC:?}"
|
||||
COMMENT="${ROLL_COMMENT:-roll ${ENV}}"
|
||||
|
||||
echo "==> fire ${DEPLOY_DOC} via SSM (tag-targeted: project=open-swe, env=${ENV})"
|
||||
CMD_ID="$(aws ssm send-command \
|
||||
--document-name "${DEPLOY_DOC}" \
|
||||
--targets "Key=tag:project,Values=open-swe" "Key=tag:env,Values=${ENV}" \
|
||||
--comment "${COMMENT}" \
|
||||
--query 'Command.CommandId' --output text)"
|
||||
echo "command: ${CMD_ID}"
|
||||
|
||||
echo "==> wait for the deploy to finish"
|
||||
IID=""
|
||||
for _ in $(seq 1 60); do
|
||||
sleep 10
|
||||
IID="$(aws ssm list-command-invocations --command-id "${CMD_ID}" \
|
||||
--query 'CommandInvocations[0].InstanceId' --output text 2>/dev/null || echo None)"
|
||||
[ -z "${IID}" ] || [ "${IID}" = "None" ] && continue
|
||||
STATUS="$(aws ssm list-command-invocations --command-id "${CMD_ID}" \
|
||||
--query 'CommandInvocations[0].Status' --output text 2>/dev/null || echo Pending)"
|
||||
case "${STATUS}" in
|
||||
Success | Failed | Cancelled | TimedOut) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${IID}" ] || [ "${IID}" = "None" ]; then
|
||||
echo "ERROR: no box picked up the deploy command (is a running open-swe ${ENV} box registered with SSM?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> deploy.sh output from ${IID}:"
|
||||
echo "----- stdout -----"
|
||||
aws ssm get-command-invocation --command-id "${CMD_ID}" --instance-id "${IID}" \
|
||||
--query 'StandardOutputContent' --output text || true
|
||||
echo "----- stderr -----"
|
||||
aws ssm get-command-invocation --command-id "${CMD_ID}" --instance-id "${IID}" \
|
||||
--query 'StandardErrorContent' --output text || true
|
||||
|
||||
# Gate on the AGGREGATE command status (Success only if EVERY targeted invocation
|
||||
# succeeded), not CommandInvocations[0] — during a userDataCausesReplacement window
|
||||
# two instances can briefly share the project/env tags, and a partial failure on the
|
||||
# other instance must not be reported as success.
|
||||
TARGETS="$(aws ssm list-commands --command-id "${CMD_ID}" \
|
||||
--query 'Commands[0].TargetCount' --output text 2>/dev/null || echo 1)"
|
||||
[ "${TARGETS}" = "1" ] || echo "WARNING: deploy fanned out to ${TARGETS} instances (expected 1)"
|
||||
AGG="$(aws ssm list-commands --command-id "${CMD_ID}" \
|
||||
--query 'Commands[0].Status' --output text 2>/dev/null || echo Failed)"
|
||||
|
||||
echo "==> aggregate deploy status: ${AGG} (across ${TARGETS} target(s))"
|
||||
[ "${AGG}" = "Success" ] || { echo "ERROR: deploy did not succeed (${AGG})" >&2; exit 1; }
|
||||
66
.github/scripts/rollback.sh
vendored
Executable file
66
.github/scripts/rollback.sh
vendored
Executable file
|
|
@ -0,0 +1,66 @@
|
|||
#!/usr/bin/env bash
|
||||
# Roll an env BACK to a prior release: re-point releases/latest/ at a chosen release
|
||||
# and re-fire the deploy. Run by rollback.yml after aws creds are configured.
|
||||
#
|
||||
# ENV dev | prod (required)
|
||||
# BUCKET open-swe-<env>-assets (required)
|
||||
# DEPLOY_DOC open-swe-<env>-deploy (required)
|
||||
# TARGET_SHA release sha to restore; blank => releases/last-good/ (optional)
|
||||
#
|
||||
# releases/latest/ is moved TRANSACTIONALLY (same as the forward deploy): pointed at
|
||||
# the target, the box rolled, and on a failed roll latest is reverted to whatever it
|
||||
# was before the rollback attempt — so a failed rollback never leaves latest at a
|
||||
# release the box could not bring up. releases/last-good/ is left untouched; advance
|
||||
# it by running a forward deploy.
|
||||
#
|
||||
# Reuses the app deploy role's existing releases/* write + tag-scoped ssm:SendCommand
|
||||
# — no new IAM. The fire/wait/gate is the SAME roll-box.sh the forward deploy uses.
|
||||
set -euo pipefail
|
||||
: "${ENV:?}" "${BUCKET:?}" "${DEPLOY_DOC:?}"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Copy a release prefix into releases/latest/ AND record the resolved sha, so
|
||||
# releases/latest/sha.txt is always a real commit a later revert can resolve.
|
||||
point_latest() { # $1 = source prefix (releases/<sha>|releases/last-good); $2 = sha to record
|
||||
local src="$1" sha="$2" f
|
||||
for f in app.tar.gz spa.tar.gz; do
|
||||
aws s3 cp "s3://${BUCKET}/${src}/${f}" "s3://${BUCKET}/releases/latest/${f}"
|
||||
done
|
||||
printf '%s\n' "${sha}" | aws s3 cp - "s3://${BUCKET}/releases/latest/sha.txt"
|
||||
}
|
||||
|
||||
if [ -n "${TARGET_SHA:-}" ]; then
|
||||
SRC="releases/${TARGET_SHA}"
|
||||
LABEL="${TARGET_SHA}"
|
||||
RESOLVED_SHA="${TARGET_SHA}"
|
||||
else
|
||||
SRC="releases/last-good"
|
||||
LABEL="last-good"
|
||||
# resolve last-good's real sha so latest/sha.txt records a commit, not a label.
|
||||
RESOLVED_SHA="$(aws s3 cp "s3://${BUCKET}/releases/last-good/sha.txt" - 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
RESOLVED_SHA="${RESOLVED_SHA:-last-good}"
|
||||
fi
|
||||
echo "==> rollback ${ENV} to ${LABEL} (s3://${BUCKET}/${SRC}/, sha=${RESOLVED_SHA})"
|
||||
|
||||
# Refuse to roll back to a release that is not fully present.
|
||||
for f in app.tar.gz spa.tar.gz; do
|
||||
aws s3 ls "s3://${BUCKET}/${SRC}/${f}" >/dev/null 2>&1 \
|
||||
|| { echo "ERROR: ${SRC}/${f} not found in s3://${BUCKET} — cannot roll back to ${LABEL}" >&2; exit 1; }
|
||||
done
|
||||
|
||||
# Capture what latest points at now, so a failed rollback can be reverted.
|
||||
PREV_SHA="$(aws s3 cp "s3://${BUCKET}/releases/latest/sha.txt" - 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
|
||||
echo "==> point releases/latest/ -> ${SRC} (was ${PREV_SHA:-<none>})"
|
||||
point_latest "${SRC}" "${RESOLVED_SHA}"
|
||||
|
||||
if ! ROLL_COMMENT="rollback ${ENV} to ${LABEL}" bash "${HERE}/roll-box.sh"; then
|
||||
if [ -n "${PREV_SHA}" ]; then
|
||||
echo "!! rollback deploy failed — reverting releases/latest/ -> ${PREV_SHA}" >&2
|
||||
point_latest "releases/${PREV_SHA}" "${PREV_SHA}"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> ${ENV} rolled back to ${LABEL}"
|
||||
echo "NOTE: releases/last-good/ is left unchanged; re-run a forward deploy to advance it."
|
||||
123
.github/workflows/build-artifacts.yml
vendored
Normal file
123
.github/workflows/build-artifacts.yml
vendored
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
name: Build & publish app artifacts
|
||||
|
||||
# T7 + T19 — build the release (SPA + Python source) and publish it to the per-env
|
||||
# S3 artifact bucket, then roll the box to it.
|
||||
#
|
||||
# push to dev → publish to open-swe-dev-assets → deploy dev box (AUTO)
|
||||
# push to main → publish to open-swe-prod-assets → deploy prod box (manual approval: env "prod")
|
||||
#
|
||||
# Two artifacts (the box's deploy.sh pulls both from releases/latest/):
|
||||
# spa.tar.gz = the built dashboard SPA (vite -> ui/.output/public). Built HERE
|
||||
# (not on the box) — the build is memory-heavy and the box is small.
|
||||
# app.tar.gz = the Python source tree (NO ui/, NO .venv). The box runs
|
||||
# `uv sync` to build a native-ARM64 venv at the real runtime path.
|
||||
#
|
||||
# Each release is uploaded under releases/<sha>/ (immutable, auditable) AND mirrored
|
||||
# to releases/latest/ (what the box pulls). Then the open-swe-<env>-deploy SSM
|
||||
# document is fired (tag-scoped to project=open-swe,env=<env>) to roll the box.
|
||||
#
|
||||
# OIDC subject alignment (matches the per-env app-role trust in infra/lib/config.ts):
|
||||
# - publish-dev declares NO `environment:` → sub = repo:…:ref:refs/heads/dev
|
||||
# - publish-prod declares `environment: prod` → sub = repo:…:environment:prod
|
||||
# (also triggers the prod Environment's required-reviewer approval gate).
|
||||
#
|
||||
# Prerequisites:
|
||||
# - repo variables AWS_DEPLOY_ROLE_APP_DEV / AWS_DEPLOY_ROLE_APP_PROD = the
|
||||
# githubdeploy-open-swe-app-<env> role ARNs (open-swe-iam CfnOutputs).
|
||||
# - the open-swe-<env> stack deployed (creates the bucket + the SSM deploy doc).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- "agent/**"
|
||||
- "ui/**"
|
||||
- "deploy/**"
|
||||
- "langgraph.json"
|
||||
- "pyproject.toml"
|
||||
- "uv.lock"
|
||||
- ".github/workflows/build-artifacts.yml"
|
||||
- ".github/scripts/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# one publish+deploy per branch at a time; never cancel an in-flight release.
|
||||
group: build-artifacts-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-dev:
|
||||
name: Publish + deploy (dev)
|
||||
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
ENV: dev
|
||||
BUCKET: open-swe-dev-assets
|
||||
DEPLOY_DOC: open-swe-dev-deploy
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build SPA (vite -> ui/.output/public)
|
||||
working-directory: ui
|
||||
# vite's bundle exceeds Node's default ~2 GB heap (the build that needed an
|
||||
# 8 GB swapfile on-box); the runner has ~16 GB, so lift the heap cap.
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
- name: Package artifacts
|
||||
run: bash .github/scripts/package-artifacts.sh
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_APP_DEV }}
|
||||
aws-region: us-east-1
|
||||
- name: Publish to S3 + roll the box
|
||||
run: bash .github/scripts/publish-and-deploy.sh
|
||||
|
||||
publish-prod:
|
||||
name: Publish + deploy (prod)
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Manual-approval gate: the "prod" Environment requires a reviewer (Adam). Also
|
||||
# makes the OIDC sub …:environment:prod (matches the prod app-role trust).
|
||||
environment: prod
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
ENV: prod
|
||||
BUCKET: open-swe-prod-assets
|
||||
DEPLOY_DOC: open-swe-prod-deploy
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
- name: Build SPA (vite -> ui/.output/public)
|
||||
working-directory: ui
|
||||
# vite's bundle exceeds Node's default ~2 GB heap (the build that needed an
|
||||
# 8 GB swapfile on-box); the runner has ~16 GB, so lift the heap cap.
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
- name: Package artifacts
|
||||
run: bash .github/scripts/package-artifacts.sh
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_APP_PROD }}
|
||||
aws-region: us-east-1
|
||||
- name: Publish to S3 + roll the box
|
||||
run: bash .github/scripts/publish-and-deploy.sh
|
||||
124
.github/workflows/cd-infra.yml
vendored
Normal file
124
.github/workflows/cd-infra.yml
vendored
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
name: Infra CD
|
||||
|
||||
# Path-filtered CDK deploy for /infra, per env, OIDC-only (no static keys).
|
||||
#
|
||||
# push to dev → CI (tsc+jest+synth) → deploy OpenSweDevStack (AUTO, CI-green-gated)
|
||||
# push to main → CI → deploy OpenSweProdStack (manual approval: env "prod")
|
||||
#
|
||||
# Why this is NOT the reusable cd-cdk.yaml: that workflow runs `cdk deploy --all`,
|
||||
# which would deploy ALL THREE stacks (incl. the OTHER env + the shared IAM stack)
|
||||
# from a single-env push — breaking the per-env dev/prod boundary. So we target one
|
||||
# stack explicitly per env. (Infra CI still uses the reusable ci-typescript-cdk.)
|
||||
#
|
||||
# The shared IAM stack (open-swe-iam — owns BOTH envs' OIDC deploy roles) is
|
||||
# intentionally NOT deployed here: it is a privileged, human-gated apply (T6), so a
|
||||
# routine dev push can never alter prod's deploy role.
|
||||
#
|
||||
# OIDC subject alignment (must match the per-env trust in infra/lib/config.ts):
|
||||
# - deploy-dev declares NO `environment:` → token sub = repo:…:ref:refs/heads/dev,
|
||||
# which is exactly what githubdeploy-open-swe-infra-dev trusts.
|
||||
# - deploy-prod declares `environment: prod` → token sub = repo:…:environment:prod,
|
||||
# which githubdeploy-open-swe-infra-prod trusts AND which triggers the GitHub
|
||||
# Environment's required-reviewer (manual approval) gate.
|
||||
#
|
||||
# Prerequisites (post-T6, when the roles exist):
|
||||
# - repo variables AWS_DEPLOY_ROLE_INFRA_DEV / AWS_DEPLOY_ROLE_INFRA_PROD = the
|
||||
# githubdeploy-open-swe-infra-<env> role ARNs (open-swe-iam CfnOutputs).
|
||||
# - a GitHub Environment named "prod" with Adam as a required reviewer.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
paths:
|
||||
- "infra/**"
|
||||
- ".github/workflows/cd-infra.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# one infra deploy per branch at a time; never cancel an in-flight deploy.
|
||||
group: cd-infra-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# CI-green precondition — re-run tsc + jest + synth on the pushed commit before
|
||||
# any deploy. A failure here blocks the deploy jobs (needs: ci).
|
||||
ci:
|
||||
name: Infra CI (pre-deploy)
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
|
||||
with:
|
||||
node-version: "24"
|
||||
working-directory: infra
|
||||
cache-dependency-path: infra/package-lock.json
|
||||
run-typecheck: true
|
||||
run-tests: true
|
||||
run-cdk-synth: true
|
||||
|
||||
deploy-dev:
|
||||
name: Deploy open-swe-dev
|
||||
needs: ci
|
||||
if: ${{ github.ref == 'refs/heads/dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: infra/package-lock.json
|
||||
- name: Install deps
|
||||
working-directory: infra
|
||||
run: npm ci
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_INFRA_DEV }}
|
||||
aws-region: us-east-1
|
||||
- name: CDK deploy (dev only)
|
||||
working-directory: infra
|
||||
# --outputs-file lets us print the stack outputs from CDK's own result
|
||||
# (the deploy role intentionally lacks cloudformation:DescribeStacks; CDK
|
||||
# gets outputs via the bootstrap cfn-exec role it assumes, so no extra grant).
|
||||
run: npx cdk deploy OpenSweDevStack --require-approval never --outputs-file cdk-outputs.json
|
||||
- name: Stack outputs
|
||||
working-directory: infra
|
||||
run: cat cdk-outputs.json
|
||||
|
||||
deploy-prod:
|
||||
name: Deploy open-swe-prod
|
||||
needs: ci
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# Manual-approval gate: the "prod" Environment requires a reviewer (Adam).
|
||||
# Also makes the OIDC sub …:environment:prod (matches the prod role trust).
|
||||
environment: prod
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
cache-dependency-path: infra/package-lock.json
|
||||
- name: Install deps
|
||||
working-directory: infra
|
||||
run: npm ci
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_INFRA_PROD }}
|
||||
aws-region: us-east-1
|
||||
- name: CDK deploy (prod only)
|
||||
working-directory: infra
|
||||
# See deploy-dev: --outputs-file avoids needing cloudformation:DescribeStacks.
|
||||
run: npx cdk deploy OpenSweProdStack --require-approval never --outputs-file cdk-outputs.json
|
||||
- name: Stack outputs
|
||||
working-directory: infra
|
||||
run: cat cdk-outputs.json
|
||||
26
.github/workflows/ci-infra.yml
vendored
Normal file
26
.github/workflows/ci-infra.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: Infra CI
|
||||
|
||||
# Path-filtered CI for the /infra CDK app (TypeScript). The existing "CI"
|
||||
# (ci.yml) covers the Python agent; this adds tsc + jest + cdk synth for /infra so
|
||||
# infra changes are gated on a PR the same way. Runs only when /infra changes.
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "infra/**"
|
||||
- ".github/workflows/ci-infra.yml"
|
||||
|
||||
jobs:
|
||||
infra-ci:
|
||||
name: Infra CI (tsc + jest + synth)
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
|
||||
with:
|
||||
node-version: "24"
|
||||
working-directory: infra
|
||||
cache-dependency-path: infra/package-lock.json
|
||||
run-typecheck: true
|
||||
run-tests: true
|
||||
run-cdk-synth: true
|
||||
34
.github/workflows/ci.yml
vendored
34
.github/workflows/ci.yml
vendored
|
|
@ -1,11 +1,14 @@
|
|||
name: Agent CI
|
||||
name: CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
# dev as well as main so every dev HEAD carries the full CI signal that
|
||||
# the dev->main promotion gate (check-dev-green.sh) reads. PR checks alone are
|
||||
# not enough: an admin-merge can land a red PR onto dev.
|
||||
branches: ["main", "dev"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
|
|
@ -15,33 +18,33 @@ concurrency:
|
|||
|
||||
jobs:
|
||||
lint:
|
||||
name: Agent lint
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra dev
|
||||
- name: Run lint
|
||||
run: make lint
|
||||
|
||||
format:
|
||||
name: Agent format check
|
||||
name: Format check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra dev
|
||||
- name: Run format check
|
||||
run: make format-check
|
||||
|
||||
unit-tests:
|
||||
name: Agent unit tests
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra dev
|
||||
- name: Run unit tests
|
||||
|
|
@ -52,8 +55,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
|
@ -69,6 +72,11 @@ jobs:
|
|||
# ui/ SPA. The fake LLM/GitHub/Slack boundaries need no secrets.
|
||||
- name: Run E2E
|
||||
working-directory: tests/e2e
|
||||
# Playwright's globalSetup runs the real `bun run build`, whose vite bundle
|
||||
# exceeds Node's default ~2 GB heap (same OOM fixed in build-artifacts.yml).
|
||||
# The runner has ~16 GB, so lift the heap cap.
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=8192"
|
||||
run: npx playwright test
|
||||
- name: Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
|
|||
15
.github/workflows/dependency-review.yml
vendored
Normal file
15
.github/workflows/dependency-review.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: Dependency Review
|
||||
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/dependency-review-action@v4
|
||||
with:
|
||||
fail-on-severity: high
|
||||
13
.github/workflows/labeler.yml
vendored
Normal file
13
.github/workflows/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
labeler:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@main
|
||||
68
.github/workflows/promote-dev-to-prod.yml
vendored
Normal file
68
.github/workflows/promote-dev-to-prod.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Promote dev to main (prod)
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: promote-dev-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
checks: read
|
||||
steps:
|
||||
# Mint a GitHub App installation token for the protected-branch push below.
|
||||
# A plain ref push by github-actions[bot] is REJECTED by the `main` ruleset
|
||||
# (PRs required + a required status check; the default token is not a bypass
|
||||
# actor). The App behind these secrets MUST be added to the `main` ruleset's
|
||||
# bypass actors; the push is then accepted and attributed to the App (not a
|
||||
# human PAT). The promoted commit already passed every check on dev (gated
|
||||
# below), so re-gating it via a PR on main would be redundant.
|
||||
- name: Mint app token for the protected-branch push
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ secrets.PROMOTE_APP_ID }}
|
||||
private-key: ${{ secrets.PROMOTE_APP_PRIVATE_KEY }}
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: dev
|
||||
fetch-depth: 0
|
||||
# Persist the App token as the git credential so the fast-forward push uses
|
||||
# the bypass-actor identity (not the default github-actions[bot]).
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Require dev HEAD fully green
|
||||
# Hard precondition: every check-run on the dev HEAD commit must be
|
||||
# completed + passing before we let it become prod. A red OR still-pending
|
||||
# check blocks the promotion. The promotion workflow's OWN check-runs are
|
||||
# excluded by the gate (this run by id, plus any STALE prior promote runs by
|
||||
# name + Actions URL) so the gate can't deadlock on itself.
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
# exclude THIS run's own check-run by its run id (not by name).
|
||||
EXCLUDE_RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
SHA="$(git rev-parse HEAD)"
|
||||
echo "dev HEAD = ${SHA}"
|
||||
gh api --paginate "repos/${GITHUB_REPOSITORY}/commits/${SHA}/check-runs" \
|
||||
-q '.check_runs[] | [.name, .status, (.conclusion // ""), (.details_url // "")] | join("\u001f")' \
|
||||
| bash .github/scripts/check-dev-green.sh
|
||||
- name: Fast-forward main (PROD) to dev
|
||||
# main is the production branch. A direct ref push is normally rejected by the
|
||||
# `main` ruleset (PRs required), so this succeeds only because the App minted
|
||||
# above is a bypass actor. The push is fast-forward-only, so a diverged main
|
||||
# fails loudly rather than force-updating.
|
||||
#
|
||||
# SECURITY: this is the LAST step on purpose. The App token persists as the
|
||||
# git credential after checkout — do not add steps after this push that run
|
||||
# untrusted code or could echo the credential.
|
||||
run: |
|
||||
git push origin HEAD:refs/heads/main
|
||||
25
.github/workflows/promote_main_to_prod.yml
vendored
25
.github/workflows/promote_main_to_prod.yml
vendored
|
|
@ -1,25 +0,0 @@
|
|||
name: Promote main to prod
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: promote-main-to-prod
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
- name: Force-push main to prod
|
||||
run: |
|
||||
git push --force origin HEAD:refs/heads/prod
|
||||
|
|
@ -78,8 +78,8 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
- uses: actions/checkout@v7
|
||||
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked
|
||||
- name: Run reviewer eval
|
||||
84
.github/workflows/rollback.yml
vendored
Normal file
84
.github/workflows/rollback.yml
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
name: Rollback (re-point env to a prior release)
|
||||
|
||||
# Roll an env back to a previously published release without rebuilding. Re-points
|
||||
# releases/latest/ at the chosen release and re-fires the open-swe-<env>-deploy SSM
|
||||
# document — same fire/wait/gate path as a forward deploy (roll-box.sh).
|
||||
#
|
||||
# env=dev, sha blank → restore open-swe-dev-assets/releases/last-good/ (AUTO)
|
||||
# env=prod, sha blank → restore open-swe-prod-assets/releases/last-good/ (manual
|
||||
# approval: Environment "prod", same gate as a prod deploy)
|
||||
# sha=<commit> → restore that exact releases/<sha>/ instead of last-good.
|
||||
#
|
||||
# No new IAM: reuses the githubdeploy-open-swe-app-<env> role's existing releases/*
|
||||
# write + tag-scoped ssm:SendCommand. OIDC subject alignment matches build-artifacts:
|
||||
# - rollback-dev declares NO `environment:` → sub = repo:…:ref:refs/heads/<branch>
|
||||
# - rollback-prod declares `environment: prod` → sub = repo:…:environment:prod
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
env:
|
||||
description: "Which environment to roll back"
|
||||
required: true
|
||||
type: choice
|
||||
options: [dev, prod]
|
||||
sha:
|
||||
description: "Release SHA to restore (blank = releases/last-good)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
# never overlap a rollback with another rollback/deploy of the same env.
|
||||
group: rollback-${{ inputs.env }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rollback-dev:
|
||||
name: Rollback (dev)
|
||||
if: ${{ inputs.env == 'dev' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
ENV: dev
|
||||
BUCKET: open-swe-dev-assets
|
||||
DEPLOY_DOC: open-swe-dev-deploy
|
||||
TARGET_SHA: ${{ inputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_APP_DEV }}
|
||||
aws-region: us-east-1
|
||||
- name: Re-point releases/latest + redeploy
|
||||
run: bash .github/scripts/rollback.sh
|
||||
|
||||
rollback-prod:
|
||||
name: Rollback (prod)
|
||||
if: ${{ inputs.env == 'prod' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
# Manual-approval gate: the "prod" Environment requires a reviewer (Adam). Also
|
||||
# makes the OIDC sub …:environment:prod (matches the prod app-role trust).
|
||||
environment: prod
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
env:
|
||||
ENV: prod
|
||||
BUCKET: open-swe-prod-assets
|
||||
DEPLOY_DOC: open-swe-prod-deploy
|
||||
TARGET_SHA: ${{ inputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: aws-actions/configure-aws-credentials@v6
|
||||
with:
|
||||
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_APP_PROD }}
|
||||
aws-region: us-east-1
|
||||
- name: Re-point releases/latest + redeploy
|
||||
run: bash .github/scripts/rollback.sh
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -68,4 +68,7 @@ __pycache__/
|
|||
*.egg-info/
|
||||
.eggs/
|
||||
|
||||
#
|
||||
# Local working docs (gitignored — survives upstream merges, never pushed)
|
||||
TODO.md
|
||||
|
||||
# infra/cdk-outputs.json
|
||||
|
|
|
|||
24
.security-review/suppressions.json
Normal file
24
.security-review/suppressions.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"suppressions": [
|
||||
{
|
||||
"id": "OSWE-IAC-AUDIT-01",
|
||||
"title": "Dev-branch infra OIDC role can assume the account-wide CDK cfn-exec admin role (cdk-hnb659fds-*), a path to mutating prod",
|
||||
"file": "infra/lib/constructs/github-deploy-roles.ts",
|
||||
"severity": "high",
|
||||
"status": "confirmed",
|
||||
"suppression_justification": "PRE-EXISTING and NOT introduced or worsened by the T7+T19 change (the assets bucket / app-role PutObject / SSM deploy doc). This is the known single-account-wide CDK cfn-exec residual already documented in infra/lib/config.ts:31-34 and the github-deploy-roles.ts construct comment, accepted at the T4 GPT-4.1 IAM cross-review and the v5 plan-review. WHO can assume each env's infra role is exact-subject scoped (StringEquals on the dev ref / prod environment); the residual is the shared account-wide cfn-exec-role that every env's infra role can reach. The tracked fix is per-env CDK bootstrap qualifiers so each env's infra role assumes its own env-scoped cfn-exec-role. Suppressed for THIS change's gate because it is out-of-diff and unchanged; surfaced to Adam for scheduling the per-env-bootstrap remediation.",
|
||||
"owner": "adam@seahavenind.com",
|
||||
"added": "2026-06-26"
|
||||
},
|
||||
{
|
||||
"id": "OSWE-IAC-SECRETS-LIST-01",
|
||||
"title": "EC2 instance role grants BatchGetSecretValue + ListSecrets on \"*\" (operation-level; secret-NAME enumeration account-wide)",
|
||||
"file": "infra/lib/constructs/instance-role.ts",
|
||||
"severity": "low",
|
||||
"status": "confirmed",
|
||||
"suppression_justification": "ACCEPTED LOW residual, metadata-only. fetch-config.sh materializes the .env via `batch-get-secret-value --filters Key=name,Values=open-swe-<env>/`. With a name FILTER, both BatchGetSecretValue (a collection call) and ListSecrets are authorized by AWS against `*`, NOT a per-secret ARN — a prefix-scoped ARN AccessDenies the call (confirmed empirically on i-0af4e03e8bf70e6c3). So the two `*` grants are operation-level, not value-level. Secret VALUES remain strictly gated by the PREFIX-scoped GetSecretValue/DescribeSecret on secret:open-swe-<env>/* (GetSecretValue is checked per-secret even within the batch), so cross-env VALUE isolation is preserved; only NAMES/tags/descriptions are enumerable, within Sea Haven's own single-tenant account 328440206208. Confirmed by GPT-4.1 IAM cross-review (BLOCK: none) and the iac-iam detector (one low residual, no critical/high). Future hardening to eliminate BOTH `*` grants: switch fetch-config.sh to an explicit `--secret-id-list` (no filter), which lets BatchGetSecretValue be prefix-scoped and needs no ListSecrets.",
|
||||
"owner": "adam@seahavenind.com",
|
||||
"added": "2026-06-26"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
FROM python:3.14.5-slim-trixie
|
||||
FROM python:3.14.6-slim-trixie
|
||||
|
||||
ARG DOCKER_CLI_VERSION=5:29.1.5-1~debian.13~trixie
|
||||
ARG NODEJS_VERSION=22.22.0-1nodesource1
|
||||
|
|
|
|||
|
|
@ -108,6 +108,10 @@ All three companies in the article converge on **Slack as the primary invocation
|
|||
|
||||
Each invocation creates a deterministic thread ID, so follow-up messages on the same issue or thread route to the same running agent.
|
||||
|
||||
**Trigger tags (Sea Haven fork):** a mention is a case-insensitive substring match on the comment body — `@openswe`, `@open-swe`, `@openswe-dev`, or `@seahaven-openswe` (the deployed App slug). GitHub won't linkify `@seahaven-openswe` (App `[bot]` accounts aren't user-mentionable), but the text still fires a run.
|
||||
|
||||
**Engineering conventions & attribution (Sea Haven fork):** the main agent's system prompt is tuned to the Sea Haven engineering handbook — branch names are `feature|bug|hotfix/<kebab-desc>` (optional resolvable `<KEY>-` prefix), PR bodies use `## Summary / Validation / Tests / Notes`, and commit messages follow the handbook format (≤50-char imperative subject, *why* over *what*). The **PR title rule is repo-aware**: when the target repo enforces a conventional-commit title (an `amannn/action-semantic-pull-request` workflow, a `commitlint` config, or a documented requirement in `AGENTS.md` / `CONTRIBUTING.md`), the agent emits a conforming `type(scope): …` title that reads the action's allowed types/scopes — this lets it pass gates like this repo's own `PR Title Lint` and upstream `langchain-ai/open-swe` without manual retitling; otherwise it falls back to the Sea Haven imperative style with no `type:` prefix. PRs that resolve a GitHub issue **auto-link it** in the body (`Closes #<n>` for full fixes, `Refs #<n>`/`Part of #<n>` for partial work, `Closes owner/repo#<n>` cross-repo); because the Sea Haven flow targets `dev` rather than the default branch, the issue closes when `dev` is promoted, not at dev-merge. **No agent/AI attribution is added to any artifact** — no `Co-authored-by` bot trailer, no `Made by [Open SWE]` footer, no "generated by an agent" notes. Commits are currently authored as the **triggering user** (the upstream behavior, which keeps Vercel preview deploys resolvable); flipping authorship to the bot account is tracked separately in issue #11 pending the Vercel-resolvability decision.
|
||||
|
||||
### 7. Validation — Prompt-Driven
|
||||
|
||||
The agent is instructed to run linters, formatters, and tests before committing, and is responsible end-to-end for committing, pushing, opening/updating the draft PR, and replying in the source channel.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from .utils.authorship import (
|
|||
OPEN_SWE_BOT_EMAIL,
|
||||
OPEN_SWE_BOT_NAME,
|
||||
CollaboratorIdentity,
|
||||
build_pr_attribution_footer,
|
||||
)
|
||||
from .utils.github_comments import UNTRUSTED_GITHUB_COMMENT_OPEN_TAG
|
||||
|
||||
|
|
@ -159,7 +158,12 @@ Before starting any task that requires code changes, set up the repository in yo
|
|||
|
||||
This sets the author of every commit you make. This is required for CI: third-party integrations (e.g. Vercel preview deploys) reject commits whose author email cannot be resolved to a GitHub account, and this email resolves. Do NOT set any other identity, do NOT pass `--author` to `git commit`, and do NOT export `GIT_AUTHOR_*` / `GIT_COMMITTER_*` env vars.
|
||||
|
||||
4. **Choose your branch** — Use a thread-stable branch name such as `open-swe/<short-task-slug>`. If a branch already exists for this thread/task, fetch and check it out instead of creating a new one.
|
||||
4. **Choose your branch** — Use a Sea Haven branch name: `<prefix>/<description>`, all kebab-case. Pick the prefix by the kind of work:
|
||||
- `feature/` — new functionality or an enhancement
|
||||
- `bug/` — a defect caught before it reaches production
|
||||
- `hotfix/` — a fix for a production-impacting issue
|
||||
|
||||
Keep `<description>` short and kebab-case (e.g. `feature/add-receipt-parser`). When a ticket key is resolvable from the run context, put it first: `feature/<KEY>-add-receipt-parser`; if no key is resolvable, omit it. Never commit directly to `main`. Keep the branch thread-stable: if a branch already exists for this thread/task, fetch and check it out instead of creating a new one.
|
||||
|
||||
5. **Checkout your branch** — Always fetch and checkout your branch before making any changes. When reusing an existing remote branch, start from `origin/<branch>` rather than recreating the branch from the base branch; this preserves prior commits for review.
|
||||
|
||||
|
|
@ -390,31 +394,55 @@ When you have completed your implementation, follow these steps in order:
|
|||
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). This attributes the PR to the triggering user. Push the branch BEFORE calling it.
|
||||
- **Update an existing PR** (edit the body, mark ready for review, etc.) with `GH_TOKEN=dummy gh pr edit`. If a PR already exists for the branch (including one the user pasted in), do NOT open a duplicate — `open_pull_request` returns the existing PR's URL, so switch to `gh pr edit`. For follow-up changes, add a new commit on top of the existing branch history.
|
||||
|
||||
**PR Title** (under 70 characters):
|
||||
```
|
||||
<type>: <concise description> [closes <TICKET>]
|
||||
```
|
||||
Where type is one of: `fix` (bug fix), `feat` (new feature), `chore` (maintenance), `ci` (CI/CD).
|
||||
Always append the resolvable ticket number in square brackets at the end of the title (e.g. `fix: handle null session [closes AB-000]`). Resolve the ticket from the Linear-triggered run when present (`{linear_project_id}-{linear_issue_number}`), or from a Linear ticket referenced in the Slack thread / task context. If no ticket number is resolvable, omit the bracketed suffix entirely.
|
||||
**PR Title** (under 70 characters): the title rule is **repo-aware** — first detect whether the target repo enforces a conventional-commit PR title, then pick the matching style. The repo is already cloned, so this check is cheap.
|
||||
|
||||
**PR Body** (keep under 10 lines total. the more concise the better):
|
||||
```
|
||||
## Description
|
||||
<1-3 sentences on WHY and the approach.
|
||||
NO "Changes:" section — file changes are already in the commit history.>
|
||||
*Detect a conventional-commit title gate* — the repo enforces one if ANY of these hold:
|
||||
- a workflow under `.github/workflows/` references `amannn/action-semantic-pull-request` (or any `semantic-pull-request` action);
|
||||
- a `commitlint` config wired to PR titles (`commitlint.config.*`, `.commitlintrc*`, or a `commitlint` key in `package.json`);
|
||||
- `AGENTS.md` / `CONTRIBUTING.md` states a conventional-commit title requirement.
|
||||
|
||||
## Release Note
|
||||
<One-line changelog summary for self-hosted customers, or "none" for internal/CI/test/refactor changes.>
|
||||
*If a gate is enforced* → emit a conventional-commit title `type(scope): description` and conform to the action's configuration. This **overrides** the Sea Haven no-`type:`-prefix default. Open the workflow (e.g. `.github/workflows/pr_lint.yml`) and read the allowed `types`/`scopes` so you stay inside them; if `requireScope` is false, a scope is optional. Map the work to a type: new functionality → `feat`, defect fix → `fix`, infra/CI → `ci`/`build`/`chore`, docs → `docs`, tests → `test`, refactor → `refactor`, perf → `perf`. Examples: `feat: add retry logic for transient upstream failures` or `fix(deps): pin langgraph-cli`. Do NOT rely on an escape-hatch label (e.g. `ignore-lint-pr-title`) to dodge the check — conform to the title instead. (Note: this repo's own `PR Title Lint` and upstream `langchain-ai/open-swe` both enforce this — emit a conforming `type:` title for them.)
|
||||
|
||||
## Test Plan
|
||||
- [ ] <new/novel verification steps only — NOT "run existing tests" or "verify existing behavior">
|
||||
*If no gate is enforced* → use the Sea Haven imperative style: imperative mood, capitalized, describing the change — not the ticket. Do NOT use a conventional-commit `type:` prefix (no `feat:`/`fix:`/`chore:`). When a ticket key is resolvable from the run context, prefix it in square brackets; otherwise omit it entirely:
|
||||
```
|
||||
[<KEY>] Add retry logic for transient upstream failures
|
||||
```
|
||||
With no resolvable key, use just the imperative description: `Add retry logic for transient upstream failures`. Resolve the key from the Linear-triggered run when present (`{linear_project_id}-{linear_issue_number}`), or from a Linear ticket referenced in the Slack thread / task context.
|
||||
|
||||
**PR Body** — use this structure. Omit a section only when it would be empty:
|
||||
```
|
||||
## Summary
|
||||
<What changed and why — 1-3 sentences. Explain the motivation, not just the diff.>
|
||||
|
||||
## Validation
|
||||
<How you verified it works — commands run, steps taken, screenshots if UI.>
|
||||
|
||||
## Tests
|
||||
<What tests were added, updated, or run. If no automated tests, explain manual testing.>
|
||||
|
||||
## Notes
|
||||
<Anything reviewers should know — migration steps, deploy order, follow-ups, breaking changes. Omit this section if empty.>
|
||||
```
|
||||
|
||||
**Link the GitHub issue the PR resolves** — when the run originates from (or fully fixes) a GitHub issue, add a closing keyword to the PR body so merging auto-closes the issue. The issue number is usually in-context: issue-triggered runs receive a `## GitHub Issue: #<n>` line; for Slack/Linear-triggered runs that fix a GitHub issue, pick `#<n>` up from the task text.
|
||||
- When the PR **fully resolves** a GitHub issue in the **same repo**, add a dedicated trailing line in `## Summary` (or its own line at the end of the body): `Closes #<n>`. GitHub recognizes `Closes`/`Fixes`/`Resolves #<n>` anywhere in the body.
|
||||
- When the PR only **partially** addresses an issue (more work remains), use a **non-closing** reference so the issue stays open: `Refs #<n>` or `Part of #<n>`.
|
||||
- **Cross-repo**: if the issue lives in a different repo, use the fully-qualified form: `Closes owner/repo#<n>` (or `Refs owner/repo#<n>` for partial).
|
||||
- This is the GitHub-issue analog of the Linear `Refs: <KEY>` commit trailer — placed in the PR body where GitHub's auto-close looks.
|
||||
- **Default-branch caveat (don't mistake this for a bug):** GitHub only auto-closes the linked issue when the PR merges into the repo's **default branch**. In the Sea Haven flow the agent targets `dev`, not the default branch, so `Closes #<n>` will **not** close the issue at dev-merge time — it closes when `dev` is promoted to the default branch. The link still renders, and the issue closes on promotion; this is the correct, expected outcome. On repos where the agent targets the default branch directly, it closes on merge as usual.
|
||||
|
||||
You don't need to add links back to the originating Slack thread or Linear ticket — for private repos, `open_pull_request` appends a `## References` section automatically.
|
||||
|
||||
When the target repo is public, don't reference private repos or private PR/issue numbers in the description.
|
||||
|
||||
**Commit message**: Concise, focusing on the "why" rather than the "what". If not provided, the PR title is used.
|
||||
**Commit message** — follow the Sea Haven format:
|
||||
- Imperative mood, capitalized first letter (e.g. "Add retry logic", not "Added retry logic" or "adds retry logic").
|
||||
- Subject line ≤50 characters. If you need more, add a blank line and a body wrapped at 72 characters.
|
||||
- Explain *why*, not *what* — the diff already shows what changed.
|
||||
- No generic subjects ("Fix stuff", "Update code", "WIP", "Address review comments") and no self-referential phrasing ("This commit…", "This PR…", "I refactored…").
|
||||
- When a ticket key is resolvable, add a `Refs: <KEY>` trailer (combine with `#<issue>` when both apply); otherwise omit the trailer.
|
||||
|
||||
This per-commit convention is independent of the repo-aware **PR title** rule above. On a repo that requires conventional PR titles **and** squash-merges, the squash commit subject becomes the PR title (e.g. `feat: …`) and so diverges from this imperative-no-prefix commit style — that's an acceptable tradeoff (the target repo's title lint wins), not a contradiction. Your own per-commit subjects still follow the Sea Haven format here.
|
||||
|
||||
**IMPORTANT: For code-change tasks, never ask the user for permission or confirmation before pushing commits or opening/updating a draft PR. Do not say "if you want, I can proceed" or "shall I open the PR?". When implementation is done and checks pass, push autonomously, and open/update a draft PR autonomously when requested, necessary, or required by the Always Create PRs dashboard setting.**
|
||||
|
||||
|
|
@ -448,23 +476,17 @@ For code-change tasks, push the branch and notify the appropriate source once im
|
|||
|
||||
COLLABORATION_TEMPLATE = """---
|
||||
|
||||
### Collaborative Attribution
|
||||
### Authorship & Attribution
|
||||
|
||||
This run was triggered by **{display_name}**. You author the work **as them** — their git identity is already configured in the Repository Setup step, so every commit and the PR are attributed to them. Credit open-swe as the collaborator:
|
||||
This run was triggered by **{display_name}**. You author the work as them — their git identity is already configured in the Repository Setup step, so every commit and the PR are attributed to them.
|
||||
|
||||
- **Commits**: append this trailer (verbatim, on its own line, separated from the message body by a blank line) to every commit message you author. Add it to both the first commit and any follow-up commits in this run:
|
||||
**Add NO agent or AI attribution to any artifact.** Sea Haven artifacts carry no agent attribution — write commits, PR descriptions, and comments exactly as a human engineer would. Specifically, never add:
|
||||
|
||||
```
|
||||
{bot_coauthor_trailer}
|
||||
```
|
||||
- A `Co-authored-by:` trailer for any bot or agent (no `open-swe[bot]`, no `Claude`, etc.).
|
||||
- A PR-body footer or tagline such as `Made by [Open SWE]`, `Generated with …`, a 🤖 emoji line, or `_Opened collaboratively by … and open-swe._`.
|
||||
- Any "created/opened by an agent" note in commits, PR bodies, or issue comments.
|
||||
|
||||
- **PR body**: append this line to the bottom of the PR description (separated from the body by a blank line) when you open or update the draft PR. Do not duplicate it if it is already present. If the PR body already contains a `Made by [Open SWE]` footer pointing at a different link, or a legacy footer like `_Opened collaboratively by {display_name} and open-swe._`, replace that existing footer with this line instead of appending a second footer:
|
||||
|
||||
```
|
||||
{pr_attribution_footer}
|
||||
```
|
||||
|
||||
If you forget the trailer on a local commit that has not been pushed, fix it with `git commit --amend` before pushing — do not push without it. If the commit has already been pushed, leave it as-is and add the trailer to your next commit; never rewrite remote history to fix it."""
|
||||
If a template or a prior artifact already contains such attribution, strip it rather than carrying it forward."""
|
||||
|
||||
|
||||
def _render_collaboration_section(
|
||||
|
|
@ -473,11 +495,7 @@ def _render_collaboration_section(
|
|||
) -> str:
|
||||
if identity is None:
|
||||
return ""
|
||||
return COLLABORATION_TEMPLATE.format(
|
||||
display_name=identity.display_name,
|
||||
pr_attribution_footer=build_pr_attribution_footer(thread_url),
|
||||
bot_coauthor_trailer=f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>",
|
||||
)
|
||||
return COLLABORATION_TEMPLATE.format(display_name=identity.display_name)
|
||||
|
||||
|
||||
ALWAYS_CREATE_PR_SECTION = """---
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Helpers for collaborative commit and PR attribution."""
|
||||
"""Helpers for resolving the triggering user's git identity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -16,16 +16,6 @@ OPEN_SWE_BOT_NAME = "open-swe[bot]"
|
|||
# accepts, which broke preview deploys on commits carrying this co-author.
|
||||
OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com"
|
||||
|
||||
PR_ATTRIBUTION_TEXT = "Made by [Open SWE]"
|
||||
PR_ATTRIBUTION_DEFAULT_URL = "https://openswe.vercel.app"
|
||||
PR_ATTRIBUTION_FOOTER = f"{PR_ATTRIBUTION_TEXT}({PR_ATTRIBUTION_DEFAULT_URL})"
|
||||
|
||||
|
||||
def build_pr_attribution_footer(thread_url: str | None = None) -> str:
|
||||
"""Build the Open SWE PR footer, linking the run's thread when available."""
|
||||
url = thread_url.strip() if isinstance(thread_url, str) and thread_url.strip() else ""
|
||||
return f"{PR_ATTRIBUTION_TEXT}({url or PR_ATTRIBUTION_DEFAULT_URL})"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollaboratorIdentity:
|
||||
|
|
@ -149,50 +139,3 @@ def resolve_triggering_user_identity(
|
|||
"""
|
||||
|
||||
return _identity_from_github_token(github_token) or _identity_from_config(config)
|
||||
|
||||
|
||||
def add_bot_coauthor_trailer(commit_message: str) -> str:
|
||||
"""Append the open-swe[bot] Co-authored-by trailer.
|
||||
|
||||
Commits are authored by the triggering user (via the repo-local git
|
||||
identity); open-swe[bot] is credited as the collaborator.
|
||||
"""
|
||||
normalized_message = commit_message.rstrip()
|
||||
trailer = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
|
||||
if trailer in normalized_message:
|
||||
return normalized_message
|
||||
return f"{normalized_message}\n\n{trailer}"
|
||||
|
||||
|
||||
def add_pr_collaboration_note(
|
||||
pr_body: str,
|
||||
identity: CollaboratorIdentity | None = None,
|
||||
thread_url: str | None = None,
|
||||
) -> str:
|
||||
"""Append the Open SWE attribution footer to a PR body.
|
||||
|
||||
The PR is opened as the triggering user, so the body only credits Open SWE
|
||||
as the collaborator. The footer links the run's thread when available. Any
|
||||
legacy double-attribution footer is replaced.
|
||||
"""
|
||||
|
||||
normalized_body = pr_body.rstrip()
|
||||
note = build_pr_attribution_footer(thread_url)
|
||||
if note in normalized_body:
|
||||
return normalized_body
|
||||
if PR_ATTRIBUTION_TEXT in normalized_body:
|
||||
return normalized_body
|
||||
|
||||
legacy_footers: list[str] = []
|
||||
if identity is not None:
|
||||
legacy_footers.append(
|
||||
f"_Opened collaboratively by {identity.pr_attribution_name} and open-swe._"
|
||||
)
|
||||
legacy_footers.append(f"_Opened collaboratively by {identity.display_name} and open-swe._")
|
||||
for legacy in legacy_footers:
|
||||
if legacy in normalized_body:
|
||||
return normalized_body.replace(legacy, note)
|
||||
|
||||
if not normalized_body:
|
||||
return note
|
||||
return f"{normalized_body}\n\n{note}"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ __all__ = [
|
|||
"verify_github_signature",
|
||||
]
|
||||
|
||||
OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev")
|
||||
OPEN_SWE_TAGS = ("@openswe", "@open-swe", "@openswe-dev", "@seahaven-openswe")
|
||||
UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "<dangerous-external-untrusted-users-comment>"
|
||||
UNTRUSTED_GITHUB_COMMENT_CLOSE_TAG = "</dangerous-external-untrusted-users-comment>"
|
||||
_SANITIZED_UNTRUSTED_GITHUB_COMMENT_OPEN_TAG = "[blocked-untrusted-comment-tag-open]"
|
||||
|
|
|
|||
167
deploy/ami/README.md
Normal file
167
deploy/ami/README.md
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# Open SWE base AMI (T8)
|
||||
|
||||
Packer recipe + first-boot user-data for the single EC2 instance per env
|
||||
(`open-swe-dev` / `open-swe-prod`) in the Open SWE → AWS migration. Builds an
|
||||
**ARM64 (Graviton) Ubuntu 24.04 LTS** base AMI and provisions the box on first
|
||||
boot with the stock `langgraph dev` runtime, nginx, and the CloudWatch agent.
|
||||
|
||||
The architecture is locked in the repo `TODO.md` ("Architecture (locked)"): ONE
|
||||
EC2 ARM64 (~t4g.large) instance per env, `seahaven-vpc` **private subnet + NAT**,
|
||||
inbound **only from the ALB SG**. Runtime is **stock `langgraph dev`** (in-memory
|
||||
store, `--no-reload`) + nginx + systemd. The box has **no git auth** — it pulls
|
||||
its deploy artifact from S3 via the instance role. The SPA build runs in GitHub
|
||||
Actions (T7), **not** on the box, so the old 8 GB-swapfile OOM hack is gone.
|
||||
|
||||
## Files
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `open-swe-base.pkr.hcl` | Packer template (HCL2). Latest Canonical 24.04 arm64 source → base AMI. |
|
||||
| `scripts/provision.sh` | Packer provisioner. System packages, uv+py3.12, node+bun, service user, stages templates. |
|
||||
| `user-data.sh` | First-boot provisioning (S3 artifact pull, render templates, CW agent, start services). |
|
||||
| `templates/open-swe.service` | systemd unit TEMPLATE (`@@tokens@@` rendered at boot). |
|
||||
| `templates/open-swe.nginx.conf` | nginx site TEMPLATE (dashboard SPA + scoped `/dashboard/api/` proxy). |
|
||||
| `templates/amazon-cloudwatch-agent.json` | CW agent config TEMPLATE — **30-day log retention**. |
|
||||
|
||||
`deploy/seahaven/fetch-config.sh` and `deploy/seahaven/seed_store.sh` are owned by
|
||||
the parallel T10 work and ship **inside the app artifact**; this AMI wires them in
|
||||
but does not author them (see "Integration contract" below).
|
||||
|
||||
## Build the AMI
|
||||
|
||||
```bash
|
||||
cd deploy/ami
|
||||
packer init .
|
||||
packer fmt -check .
|
||||
packer validate -var aws_region=us-east-1 open-swe-base.pkr.hcl
|
||||
packer build open-swe-base.pkr.hcl
|
||||
```
|
||||
|
||||
Builds in account **328440206208 / us-east-1**. Source = latest Canonical Ubuntu
|
||||
24.04 (Noble) **arm64** AMI (`source_ami_filter`, owner `099720109477`). Build host
|
||||
is `t4g.medium` (ARM64). The output AMI is tagged:
|
||||
|
||||
```
|
||||
Name=open-swe-base-arm64 Purpose=open-swe-runtime-base ManagedBy=packer
|
||||
```
|
||||
|
||||
Pinned versions live in the template `variable` defaults (`uv_version`,
|
||||
`python_version`, `node_major`, the CW-agent / awscli URLs) and the
|
||||
`required_plugins` block (`amazon` 1.3.6) — bump deliberately.
|
||||
|
||||
## AMI → `cdk.context.json` pinning contract
|
||||
|
||||
The CDK stacks in `/infra` (owned by T3/T12) consume the AMI **by id, pinned in the
|
||||
committed `infra/cdk.context.json`** — they never resolve "latest" at synth time.
|
||||
This is the EBS/AMI-fix discipline: an uncached `MachineImage.lookup` resolves a new
|
||||
AMI on every deploy and silently triggers instance replacement.
|
||||
|
||||
Contract (CDK side does the wiring; this is the handshake):
|
||||
|
||||
1. `packer build` prints the new AMI id (and tags it `open-swe-base-arm64`).
|
||||
2. CDK looks the AMI up with **`cachedInContext: true`** (e.g.
|
||||
`MachineImage.lookup({ name: "open-swe-base-arm64-*", owners: ["328440206208"], cachedInContext: true })`),
|
||||
which writes the resolved id into `infra/cdk.context.json`.
|
||||
3. **`infra/cdk.context.json` is committed.** From then on every synth/deploy uses
|
||||
the pinned id — no surprise replacement when a newer AMI exists.
|
||||
4. To adopt a new AMI: `cdk context --reset <ami-lookup-key>` (or edit the pinned
|
||||
value), commit the change, and review the cdk-diff — the PR will show
|
||||
"requires replacement", which is the intended, visible signal.
|
||||
|
||||
Record the built AMI id in project memory (`project_open_swe_migration`) per the
|
||||
"memory updated for AMI id" build criterion.
|
||||
|
||||
## `userDataCausesReplacement` rationale
|
||||
|
||||
`user-data.sh` is **provisioning-only** — it runs once at first boot and never
|
||||
carries durable runtime config. CDK sets **`userDataCausesReplacement: true`** so
|
||||
that any change to it is a deliberate, diff-visible instance replacement rather than
|
||||
a no-op edit that drifts from the running box. Durable runtime config is fetched
|
||||
**fresh on every service start** by `fetch-config.sh` (ExecStartPre) — changing a
|
||||
secret or SSM value needs only a `systemctl restart open-swe.service`, not a
|
||||
replacement.
|
||||
|
||||
## EBS discipline (binding — `feedback_inline_ebs_volumes`)
|
||||
|
||||
**The box holds no durable state of its own:**
|
||||
|
||||
| State | Lives in | On replacement |
|
||||
|---|---|---|
|
||||
| secrets / config | Secrets Manager + SSM → tmpfs `.env` | re-fetched at boot |
|
||||
| app code + SPA | S3 `open-swe-<env>-assets` | re-pulled at boot |
|
||||
| store (team_settings, user_mappings) | reseeded by `seed_store.sh` | re-seeded at boot |
|
||||
| logs | CloudWatch (30-day) — **not** a CFN resource in the stack | survive replacement |
|
||||
|
||||
→ **No local-only durable state ⇒ no standalone RETAIN volume is needed.** The root
|
||||
volume is disposable; there is intentionally no inline data `blockDevices` to lose.
|
||||
|
||||
**Even so, snapshot before any replacing deploy.** Per the operational guard, before
|
||||
merging/deploying any change that REPLACES the instance (`userDataCausesReplacement`,
|
||||
AMI bump, instance-type change):
|
||||
|
||||
1. Enumerate the instance's volumes and assert **"no local-only durable state"**
|
||||
(the table above is the checklist).
|
||||
2. Take an **EBS snapshot of the root volume and WAIT for `state=completed`** before
|
||||
letting the deploy proceed. Keep it as insurance; delete after a grace period.
|
||||
3. Confirm the CloudWatch log groups are **not** CFN-managed in the stack so history
|
||||
survives; re-verify history after the new instance is healthy.
|
||||
|
||||
cdk-diff-on-PR must flag "requires replacement" at review time (T8/T12 acceptance
|
||||
criterion). This is the enforced version — not just an assertion in the runbook.
|
||||
|
||||
## Integration contract (T10 — `fetch-config.sh` + `seed_store.sh`)
|
||||
|
||||
Both ship in the app artifact under `deploy/seahaven/` and are wired into the unit:
|
||||
|
||||
- **`fetch-config.sh`** (ExecStartPre, runs as `openswe`): reads `/etc/open-swe/boot.env`
|
||||
(`OPENSWE_ENV`, `AWS_REGION`, `SECRETS_PREFIX=open-swe-<env>`, `SSM_PREFIX=/open-swe-<env>`,
|
||||
`ENV_FILE=/run/open-swe/.env`), pulls Secrets Manager `open-swe-<env>/*` + SSM
|
||||
`/open-swe-<env>/*`, and writes:
|
||||
- `/run/open-swe/.env` (**0600, tmpfs**, secret-bearing app env incl. the multiline
|
||||
GitHub App PEM) — loaded by langgraph/dotenv via the `${APP_DIR}/.env` symlink.
|
||||
- `/run/open-swe/seed.env` (**0600, tmpfs**, simple `OPENSWE_*` vars only:
|
||||
`OPENSWE_DEFAULT_REPO`, `OPENSWE_OWNER_LOGIN`, `OPENSWE_OWNER_EMAIL`, model ids) —
|
||||
loaded by systemd `EnvironmentFile` so `seed_store.sh` (ExecStartPost) has them.
|
||||
- It must **fail-fast** (non-zero exit) if any required value is missing, so the
|
||||
unit never starts half-configured.
|
||||
- **`seed_store.sh`** (ExecStartPost): existing script, reseeds `team_settings/default`
|
||||
+ `user_mappings/<login>` into the in-memory store after each start.
|
||||
|
||||
## Smoke-boot checklist (after first boot)
|
||||
|
||||
SSM Session Manager onto the instance (no public SSH — private subnet) and verify:
|
||||
|
||||
- [ ] `cloud-init status --wait` → `done`; `/var/log/open-swe-user-data.log` ends with
|
||||
"user-data done" and shows the S3 pulls + service starts.
|
||||
- [ ] `systemctl is-active open-swe.service` → `active`. (If it failed, check
|
||||
`ExecStartPre`/`fetch-config.sh` — fail-fast means missing config = failed unit.)
|
||||
- [ ] **fetch-config fail-fast works:** `/run/open-swe/.env` exists, owner `openswe`,
|
||||
mode `0600`, on tmpfs (`findmnt /run/open-swe`); `seed.env` present.
|
||||
- [ ] `curl -fsS http://127.0.0.1:2024/ok` → `200` (raw LangGraph health).
|
||||
- [ ] `systemctl is-active nginx` → `active`; `curl -fsS http://127.0.0.1/healthz` →
|
||||
`200`; `curl -s http://127.0.0.1/threads` returns the SPA shell, **not** JSON
|
||||
(proves the agent API is not proxied — the security boundary holds).
|
||||
- [ ] `seed_store: done` in the journal / app.log (store reseeded).
|
||||
- [ ] CloudWatch: log groups `/open-swe/<env>/{app,user-data,nginx-access,nginx-error}`
|
||||
exist with **30-day** retention and are receiving events.
|
||||
- [ ] **No swapfile** (`swapon --show` empty) — the on-box SPA build is gone.
|
||||
- [ ] From the ALB only: dashboard host serves the SPA; `hooks` host reaches
|
||||
`/webhooks/*` on :2024 and nothing else (raw API paths hit the ALB default, not
|
||||
the box).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- **Artifact bucket** `open-swe-<env>-assets` (T7), with objects
|
||||
`${ARTIFACT_PREFIX}/app.tar.gz` (Python app incl. `deploy/seahaven/` and a prebuilt
|
||||
arm64 `.venv`) and `${ARTIFACT_PREFIX}/spa.tar.gz` (built SPA → `/var/www/open-swe`).
|
||||
`ARTIFACT_PREFIX` defaults to `releases/latest`; CDK renders the concrete value.
|
||||
- **Instance role** (defined in `/infra`, least-privilege per T4/T12) grants:
|
||||
`s3:GetObject` on `open-swe-<env>-assets/*`; `secretsmanager:GetSecretValue` on
|
||||
`open-swe-<env>/*`; `ssm:GetParameter(s)`/`GetParametersByPath` on `/open-swe-<env>/*`;
|
||||
`logs:*` for the CW agent log groups + `cloudwatch:PutMetricData`; SSM Session
|
||||
Manager (`ssm:UpdateInstanceInformation`, `ssmmessages:*`) for shell access.
|
||||
- **CDK substitutes** the `@@OPENSWE_ENV@@`, `@@ASSETS_BUCKET@@`, `@@SERVER_NAME@@`,
|
||||
`@@ARTIFACT_PREFIX@@` tokens in `user-data.sh` when rendering the launch template.
|
||||
- `:2024` binds `0.0.0.0` so the ALB hooks target group can reach `/webhooks/*`; it is
|
||||
reachable only from the ALB SG (private subnet, SG-scoped inbound). The raw API is
|
||||
never internet-exposed — the ALB hooks rule is path-scoped to `/webhooks/*`.
|
||||
102
deploy/ami/deploy.sh
Executable file
102
deploy/ami/deploy.sh
Executable file
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env bash
|
||||
# Open SWE app deploy — RE-RUNNABLE (first boot + every subsequent release).
|
||||
#
|
||||
# Pulls the current release from S3 (open-swe-<env>-assets), builds the venv
|
||||
# natively on the box, and restarts the service. This is the SINGLE source of the
|
||||
# app-deploy procedure; it runs in two places:
|
||||
#
|
||||
# 1. first boot — user-data.sh decodes this script to /opt/open-swe/bin and
|
||||
# calls it ONCE (non-fatal: if no release is published yet,
|
||||
# nginx is already up and the box waits for the first deploy).
|
||||
# 2. every release — the `open-swe-<env>-deploy` SSM document (CI fires it after
|
||||
# uploading app.tar.gz / spa.tar.gz) runs this same script.
|
||||
#
|
||||
# It deploys CODE + STATIC ASSETS only. Secrets/config are NOT fetched here: the
|
||||
# systemd unit's ExecStartPre=fetch-config.sh materializes the tmpfs .env on every
|
||||
# (re)start, fail-fast — so `systemctl restart` below is what reloads config too.
|
||||
#
|
||||
# Contract:
|
||||
# app.tar.gz = the Python source tree (pyproject.toml + uv.lock + agent/ +
|
||||
# deploy/ + langgraph.json + README.md, NO ui/, NO .venv). The venv
|
||||
# is built HERE with `uv sync` so it is native ARM64 and lives at
|
||||
# the real runtime path (no cross-built / non-relocatable venv).
|
||||
# spa.tar.gz = the built dashboard SPA (vite output: _shell.html + assets),
|
||||
# extracted to the nginx web root.
|
||||
set -euo pipefail
|
||||
exec > >(tee -a /var/log/open-swe/deploy.log) 2>&1
|
||||
echo "==> open-swe deploy start $(date -u +%FT%TZ)"
|
||||
|
||||
# Non-secret pointers written by user-data.sh (env, region, bucket, artifact prefix).
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/open-swe/boot.env
|
||||
export AWS_DEFAULT_REGION="${AWS_REGION:?boot.env missing AWS_REGION}"
|
||||
: "${ASSETS_BUCKET:?boot.env missing ASSETS_BUCKET}"
|
||||
: "${ARTIFACT_PREFIX:?boot.env missing ARTIFACT_PREFIX}"
|
||||
|
||||
# Fixed layout — must match provision.sh + user-data.sh + the templates.
|
||||
SERVICE_USER="openswe"
|
||||
APP_DIR="/opt/open-swe/app"
|
||||
WWW_ROOT="/var/www/open-swe"
|
||||
ENV_FILE="/run/open-swe/.env"
|
||||
UV_BIN="/usr/local/bin/uv"
|
||||
UV_PYTHON_INSTALL_DIR="/opt/uv/python" # where provision.sh pre-installed py3.12
|
||||
SERVICE_HOME="/opt/open-swe"
|
||||
|
||||
# Benign-vs-failure distinction: on a brand-new env no release is published yet.
|
||||
# Treat "app.tar.gz absent in S3" as a benign no-op (exit 0) so first boot is not a
|
||||
# scary failure; ONCE a release exists, any later step failing is loud (set -e).
|
||||
if ! aws s3 ls "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/app.tar.gz" >/dev/null 2>&1; then
|
||||
echo "==> no release published at s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/ yet — nothing to deploy"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "==> pull release from s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/"
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
aws s3 cp "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/app.tar.gz" "${tmp}/app.tar.gz"
|
||||
aws s3 cp "s3://${ASSETS_BUCKET}/${ARTIFACT_PREFIX}/spa.tar.gz" "${tmp}/spa.tar.gz"
|
||||
|
||||
# Replace app source + SPA atomically-ish: clear the dirs (drops files removed in
|
||||
# this release) then extract. The venv is rebuilt below, so wiping .venv too is
|
||||
# fine — uv's cache (in the service home) makes the rebuild fast.
|
||||
#
|
||||
# Hardening: deploy.sh runs as root, so extract with --no-same-owner
|
||||
# --no-same-permissions — files take root:root + umask perms (NOT the archive's
|
||||
# uid/mode), so a tarball cannot land a setuid/setgid binary or a foreign-owned
|
||||
# file; the chown -R below then hands the tree to the service user. (GNU tar also
|
||||
# refuses `..`-escaping members by default.) Defense-in-depth: the only writer of
|
||||
# this bucket is the CI OIDC app role, but the box never trusts the archive's
|
||||
# ownership/mode regardless.
|
||||
echo "==> install app source -> ${APP_DIR}"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$APP_DIR" "$WWW_ROOT"
|
||||
find "$APP_DIR" -mindepth 1 -delete
|
||||
find "$WWW_ROOT" -mindepth 1 -delete
|
||||
tar --no-same-owner --no-same-permissions -xzf "${tmp}/app.tar.gz" -C "$APP_DIR"
|
||||
tar --no-same-owner --no-same-permissions -xzf "${tmp}/spa.tar.gz" -C "$WWW_ROOT"
|
||||
# langgraph reads ./.env from WorkingDirectory; point it at the tmpfs file the
|
||||
# systemd ExecStartPre materializes.
|
||||
ln -sfn "$ENV_FILE" "${APP_DIR}/.env"
|
||||
chown -R "$SERVICE_USER":"$SERVICE_USER" "$APP_DIR" "$WWW_ROOT"
|
||||
|
||||
echo "==> build venv natively (uv sync --frozen --no-dev)"
|
||||
# Run as the service user so the venv + uv cache are owned by it. Pin the
|
||||
# pre-baked interpreter dir so uv never reaches out to download Python at deploy.
|
||||
cd "$APP_DIR"
|
||||
sudo -u "$SERVICE_USER" env \
|
||||
HOME="$SERVICE_HOME" \
|
||||
UV_PYTHON_INSTALL_DIR="$UV_PYTHON_INSTALL_DIR" \
|
||||
UV_CACHE_DIR="${SERVICE_HOME}/.cache/uv" \
|
||||
"$UV_BIN" sync --frozen --no-dev
|
||||
|
||||
echo "==> restart open-swe.service + reload nginx"
|
||||
# ExecStartPre=fetch-config.sh fails-fast if secrets/config are missing, so a
|
||||
# restart here surfaces a bad config as a failed unit (non-zero exit below).
|
||||
systemctl restart open-swe.service
|
||||
nginx -t && systemctl reload nginx
|
||||
|
||||
if systemctl is-active --quiet open-swe.service; then
|
||||
echo "==> open-swe deploy OK $(date -u +%FT%TZ)"
|
||||
else
|
||||
echo "!! open-swe.service is not active after deploy (check fetch-config/secrets)"
|
||||
exit 1
|
||||
fi
|
||||
143
deploy/ami/open-swe-base.pkr.hcl
Normal file
143
deploy/ami/open-swe-base.pkr.hcl
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
# Open SWE base AMI — ARM64 (Graviton) Ubuntu 24.04 LTS.
|
||||
#
|
||||
# Builds the immutable base image for the single EC2 instance per env
|
||||
# (open-swe-dev / open-swe-prod) in seahaven-vpc. The image bakes the runtime
|
||||
# (uv + Python 3.12, nginx, awscli v2, CloudWatch agent) and the service-user /
|
||||
# systemd / nginx TEMPLATES. It bakes NO secrets and NO env-specific values —
|
||||
# those are materialized at first boot by user-data + deploy/seahaven/fetch-config.sh
|
||||
# (Secrets Manager + SSM -> root-only tmpfs .env, fail-fast).
|
||||
#
|
||||
# Build: packer init . && packer build open-swe-base.pkr.hcl
|
||||
# The resulting AMI id is pinned in infra/cdk.context.json (CDK cachedInContext:true);
|
||||
# see README.md "AMI -> cdk.context.json pinning contract".
|
||||
|
||||
packer {
|
||||
required_version = ">= 1.11.0, < 2.0.0"
|
||||
required_plugins {
|
||||
amazon = {
|
||||
source = "github.com/hashicorp/amazon"
|
||||
version = "1.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "aws_region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
default = "t4g.medium" # ARM64 (Graviton) build host; runtime instances are ~t4g.large
|
||||
}
|
||||
|
||||
variable "ami_name_prefix" {
|
||||
type = string
|
||||
default = "open-swe-base-arm64"
|
||||
}
|
||||
|
||||
# Versions baked into the image. Pin and bump deliberately.
|
||||
variable "python_version" {
|
||||
type = string
|
||||
default = "3.12"
|
||||
}
|
||||
|
||||
variable "node_major" {
|
||||
type = string
|
||||
default = "24"
|
||||
}
|
||||
|
||||
variable "uv_version" {
|
||||
type = string
|
||||
default = "0.11.24"
|
||||
}
|
||||
|
||||
variable "cloudwatch_agent_deb_url" {
|
||||
type = string
|
||||
default = "https://amazoncloudwatch-agent.s3.amazonaws.com/ubuntu/arm64/latest/amazon-cloudwatch-agent.deb"
|
||||
}
|
||||
|
||||
variable "awscli_zip_url" {
|
||||
type = string
|
||||
default = "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip"
|
||||
}
|
||||
|
||||
locals {
|
||||
timestamp = formatdate("YYYYMMDD-hhmmss", timestamp())
|
||||
}
|
||||
|
||||
# Latest Canonical Ubuntu 24.04 (Noble) arm64 server image.
|
||||
source "amazon-ebs" "open-swe" {
|
||||
region = var.aws_region
|
||||
instance_type = var.instance_type
|
||||
ssh_username = "ubuntu"
|
||||
|
||||
ami_name = "${var.ami_name_prefix}-${local.timestamp}"
|
||||
# ASCII only — AWS rejects non-ASCII in the AMI Description attribute.
|
||||
ami_description = "Open SWE base - Ubuntu 24.04 arm64 + uv/py3.12 + nginx + CW agent (templates only, no secrets)"
|
||||
|
||||
source_ami_filter {
|
||||
filters = {
|
||||
name = "ubuntu/images/hvm-ssd*/ubuntu-noble-24.04-arm64-server-*"
|
||||
architecture = "arm64"
|
||||
root-device-type = "ebs"
|
||||
virtualization-type = "hvm"
|
||||
}
|
||||
owners = ["099720109477"] # Canonical
|
||||
most_recent = true
|
||||
}
|
||||
|
||||
# IMDSv2 required on the build host.
|
||||
metadata_options {
|
||||
http_endpoint = "enabled"
|
||||
http_tokens = "required"
|
||||
http_put_response_hop_limit = 1
|
||||
}
|
||||
|
||||
# gp3 root, encrypted. Runtime root size is set by CDK; this is just the build host.
|
||||
launch_block_device_mappings {
|
||||
device_name = "/dev/sda1"
|
||||
volume_size = 20
|
||||
volume_type = "gp3"
|
||||
encrypted = true
|
||||
delete_on_termination = true
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "open-swe-base-arm64"
|
||||
Purpose = "open-swe-runtime-base"
|
||||
ManagedBy = "packer"
|
||||
}
|
||||
}
|
||||
|
||||
build {
|
||||
name = "open-swe-base"
|
||||
sources = ["source.amazon-ebs.open-swe"]
|
||||
|
||||
# Stage the boot-time templates into the image. The destination dir must exist
|
||||
# BEFORE a trailing-slash (contents-only) file upload — packer's file provisioner
|
||||
# does not create it, and uploading the directory itself trips scp ("Is a
|
||||
# directory"). So mkdir first, then upload the contents into it.
|
||||
provisioner "shell" {
|
||||
inline = ["mkdir -p /tmp/open-swe-templates"]
|
||||
}
|
||||
|
||||
provisioner "file" {
|
||||
source = "${path.root}/templates/"
|
||||
destination = "/tmp/open-swe-templates"
|
||||
}
|
||||
|
||||
provisioner "shell" {
|
||||
environment_vars = [
|
||||
"PYTHON_VERSION=${var.python_version}",
|
||||
"NODE_MAJOR=${var.node_major}",
|
||||
"UV_VERSION=${var.uv_version}",
|
||||
"CLOUDWATCH_AGENT_DEB_URL=${var.cloudwatch_agent_deb_url}",
|
||||
"AWSCLI_ZIP_URL=${var.awscli_zip_url}",
|
||||
]
|
||||
# {{ .Vars }} MUST be included or the environment_vars above never reach the
|
||||
# script (provision.sh runs under `set -u` and fails on the first reference).
|
||||
execute_command = "chmod +x {{ .Path }}; {{ .Vars }} sudo -E bash '{{ .Path }}'"
|
||||
script = "${path.root}/scripts/provision.sh"
|
||||
}
|
||||
}
|
||||
105
deploy/ami/scripts/provision.sh
Executable file
105
deploy/ami/scripts/provision.sh
Executable file
|
|
@ -0,0 +1,105 @@
|
|||
#!/usr/bin/env bash
|
||||
# Packer provisioner for the Open SWE base AMI (ARM64 Ubuntu 24.04).
|
||||
#
|
||||
# Bakes the runtime + boot-time templates ONLY. No secrets, no env-specific
|
||||
# values. Everything env-specific is materialized at first boot by user-data.sh
|
||||
# + deploy/seahaven/fetch-config.sh.
|
||||
set -euo pipefail
|
||||
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.12}"
|
||||
NODE_MAJOR="${NODE_MAJOR:-24}"
|
||||
UV_VERSION="${UV_VERSION:-0.11.24}"
|
||||
CLOUDWATCH_AGENT_DEB_URL="${CLOUDWATCH_AGENT_DEB_URL:?}"
|
||||
AWSCLI_ZIP_URL="${AWSCLI_ZIP_URL:?}"
|
||||
|
||||
# Layout (must match user-data.sh and the templates).
|
||||
SERVICE_USER="openswe"
|
||||
APP_DIR="/opt/open-swe/app"
|
||||
SERVICE_HOME="/opt/open-swe"
|
||||
WWW_ROOT="/var/www/open-swe"
|
||||
TEMPLATE_DIR="/opt/open-swe/templates"
|
||||
LOG_DIR="/var/log/open-swe"
|
||||
UV_BIN="/usr/local/bin/uv"
|
||||
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
echo "==> apt base packages"
|
||||
apt-get update -y
|
||||
apt-get upgrade -y
|
||||
apt-get install -y --no-install-recommends \
|
||||
nginx jq curl unzip ca-certificates gnupg lsb-release \
|
||||
build-essential pkg-config git acl
|
||||
|
||||
echo "==> awscli v2 (aarch64)"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$AWSCLI_ZIP_URL" -o "$tmp/awscliv2.zip"
|
||||
unzip -q "$tmp/awscliv2.zip" -d "$tmp"
|
||||
"$tmp/aws/install" --update
|
||||
rm -rf "$tmp"
|
||||
aws --version
|
||||
|
||||
echo "==> CloudWatch agent (arm64)"
|
||||
tmp="$(mktemp -d)"
|
||||
curl -fsSL "$CLOUDWATCH_AGENT_DEB_URL" -o "$tmp/amazon-cloudwatch-agent.deb"
|
||||
dpkg -i -E "$tmp/amazon-cloudwatch-agent.deb"
|
||||
rm -rf "$tmp"
|
||||
# Do NOT enable/start the agent during the build; user-data fetches its config
|
||||
# (with env-specific log-group names + 30-day retention) and starts it at boot.
|
||||
systemctl disable amazon-cloudwatch-agent.service || true
|
||||
|
||||
echo "==> uv ${UV_VERSION} + Python ${PYTHON_VERSION} (system-wide)"
|
||||
export UV_INSTALL_DIR=/usr/local/bin
|
||||
curl -fsSL "https://astral.sh/uv/${UV_VERSION}/install.sh" | env UV_NO_MODIFY_PATH=1 sh
|
||||
"$UV_BIN" --version
|
||||
# Pre-install the interpreter so the box never reaches out at boot to build a venv.
|
||||
UV_PYTHON_INSTALL_DIR=/opt/uv/python "$UV_BIN" python install "$PYTHON_VERSION"
|
||||
|
||||
echo "==> node ${NODE_MAJOR} + bun (build-time UI tooling only; the SPA is built in CI)"
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
|
||||
apt-get install -y --no-install-recommends nodejs
|
||||
node --version
|
||||
# bun installed system-wide; used only if any UI tooling must run on-box. The
|
||||
# production SPA build runs in GitHub Actions -> S3 (no on-box build, no swapfile).
|
||||
export BUN_INSTALL=/usr/local
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
/usr/local/bin/bun --version || true
|
||||
|
||||
echo "==> non-login service user '${SERVICE_USER}'"
|
||||
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
|
||||
useradd --system --create-home --home-dir "$SERVICE_HOME" \
|
||||
--shell /usr/sbin/nologin "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
echo "==> directories"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$SERVICE_HOME" "$APP_DIR"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0755 "$WWW_ROOT"
|
||||
install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$LOG_DIR"
|
||||
install -d -o root -g root -m 0755 "$TEMPLATE_DIR"
|
||||
|
||||
echo "==> stage boot-time templates into the image"
|
||||
cp /tmp/open-swe-templates/* "$TEMPLATE_DIR/"
|
||||
chown root:root "$TEMPLATE_DIR"/*
|
||||
chmod 0644 "$TEMPLATE_DIR"/*
|
||||
rm -rf /tmp/open-swe-templates
|
||||
|
||||
echo "==> tmpfs for the runtime .env (root/owner-only, noexec/nosuid/nodev)"
|
||||
# /run is already tmpfs on Ubuntu; this is an explicit, deliberately-small mount
|
||||
# scoped to the service user so the materialized .env never touches disk.
|
||||
if ! grep -q '/run/open-swe' /etc/fstab; then
|
||||
cat >>/etc/fstab <<EOF
|
||||
tmpfs /run/open-swe tmpfs rw,nosuid,nodev,noexec,mode=0700,uid=${SERVICE_USER},gid=${SERVICE_USER},size=8m 0 0
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "==> disable nginx default site (open-swe site is installed at boot)"
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
systemctl enable nginx
|
||||
|
||||
echo "==> harden: no password auth, IMDSv2 already enforced by launch template"
|
||||
# (sshd is not exposed publicly — instance is in a private subnet, SG inbound = ALB only.)
|
||||
|
||||
echo "==> clean apt caches"
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
echo "==> provision complete"
|
||||
55
deploy/ami/templates/amazon-cloudwatch-agent.json
Normal file
55
deploy/ami/templates/amazon-cloudwatch-agent.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"agent": {
|
||||
"metrics_collection_interval": 60,
|
||||
"run_as_user": "root"
|
||||
},
|
||||
"metrics": {
|
||||
"namespace": "open-swe/@@OPENSWE_ENV@@",
|
||||
"append_dimensions": {
|
||||
"InstanceId": "${aws:InstanceId}"
|
||||
},
|
||||
"metrics_collected": {
|
||||
"mem": { "measurement": ["mem_used_percent"] },
|
||||
"disk": {
|
||||
"measurement": ["used_percent"],
|
||||
"resources": ["/"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"logs_collected": {
|
||||
"files": {
|
||||
"collect_list": [
|
||||
{
|
||||
"file_path": "/var/log/open-swe/app.log",
|
||||
"log_group_name": "/open-swe/@@OPENSWE_ENV@@/app",
|
||||
"log_stream_name": "{instance_id}",
|
||||
"retention_in_days": 30,
|
||||
"timezone": "UTC"
|
||||
},
|
||||
{
|
||||
"file_path": "/var/log/open-swe-user-data.log",
|
||||
"log_group_name": "/open-swe/@@OPENSWE_ENV@@/user-data",
|
||||
"log_stream_name": "{instance_id}",
|
||||
"retention_in_days": 30,
|
||||
"timezone": "UTC"
|
||||
},
|
||||
{
|
||||
"file_path": "/var/log/nginx/access.log",
|
||||
"log_group_name": "/open-swe/@@OPENSWE_ENV@@/nginx-access",
|
||||
"log_stream_name": "{instance_id}",
|
||||
"retention_in_days": 30,
|
||||
"timezone": "UTC"
|
||||
},
|
||||
{
|
||||
"file_path": "/var/log/nginx/error.log",
|
||||
"log_group_name": "/open-swe/@@OPENSWE_ENV@@/nginx-error",
|
||||
"log_stream_name": "{instance_id}",
|
||||
"retention_in_days": 30,
|
||||
"timezone": "UTC"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
deploy/ami/templates/open-swe.nginx.conf
Normal file
62
deploy/ami/templates/open-swe.nginx.conf
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# Open SWE dashboard frontend (TanStack Start SPA) + scoped API proxy.
|
||||
# TEMPLATE: tokens (@@...@@) are rendered at first boot by user-data.sh.
|
||||
#
|
||||
# nginx is the SOLE ingress and security boundary (T5 OSWE-IAC-03): the backend
|
||||
# binds 127.0.0.1:2024 and is NOT network-reachable. nginx proxies exactly two
|
||||
# prefixes to it — /dashboard/api/* and /webhooks/* — and nothing else. The
|
||||
# unauthenticated LangGraph agent API (/threads, /runs, /assistants, /store) is
|
||||
# NEVER proxied; those paths return the SPA shell.
|
||||
#
|
||||
# Both ALB target groups (dashboard host + hooks host) point at this nginx :80,
|
||||
# not at :2024 directly, so there is no path to the raw control plane even from
|
||||
# inside the SG. Webhook signature verification still happens in the app (the raw
|
||||
# body + GitHub/Slack/Linear signature headers are passed through unmodified).
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name @@SERVER_NAME@@;
|
||||
|
||||
root @@WWW_ROOT@@;
|
||||
index _shell.html;
|
||||
|
||||
# ALB target-group health check (dashboard TG).
|
||||
location = /healthz { default_type text/plain; return 200 "ok\n"; }
|
||||
|
||||
# Dashboard API + OAuth callback -> backend webapp.
|
||||
location /dashboard/api/ {
|
||||
proxy_pass http://@@BACKEND_ADDR@@;
|
||||
proxy_http_version 1.1;
|
||||
client_max_body_size 10m;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Inbound webhooks (GitHub/Slack/Linear) -> backend webapp. Routed through
|
||||
# nginx so :2024 stays loopback-only (T5 OSWE-IAC-03). The raw request body +
|
||||
# signature headers pass through unmodified for in-app signature verification.
|
||||
location /webhooks/ {
|
||||
proxy_pass http://@@BACKEND_ADDR@@;
|
||||
proxy_http_version 1.1;
|
||||
# GitHub permits webhook payloads up to 25 MB; nginx's 1 MB default would
|
||||
# 413 large push/PR events at the edge BEFORE in-app signature verification
|
||||
# runs, silently dropping them (OSWE-T12-01). proxy_request_buffering off
|
||||
# does not relax the size cap — set it explicitly.
|
||||
client_max_body_size 25m;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_request_buffering off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Static assets + SPA shell fallback (client-side routing).
|
||||
location / {
|
||||
try_files $uri $uri/ /_shell.html;
|
||||
}
|
||||
}
|
||||
54
deploy/ami/templates/open-swe.service
Normal file
54
deploy/ami/templates/open-swe.service
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# Open SWE — stock LangGraph dev server (3+ graphs + FastAPI webapp, :2024).
|
||||
# TEMPLATE: tokens (@@...@@) are rendered at first boot by user-data.sh.
|
||||
# In-memory runtime (--no-reload) + ExecStartPost reseed; no Aegra/Postgres.
|
||||
#
|
||||
# Boot contract:
|
||||
# ExecStartPre = fetch-config.sh <env> -> runs as root (`+`) ONLY to materialize
|
||||
# the SERVICE-USER-owned tmpfs .env (@@ENV_FILE@@) from Secrets
|
||||
# Manager + SSM and chown it to @@SERVICE_USER@@, fail-fast (the
|
||||
# unit does NOT start if config can't be fetched).
|
||||
# ExecStart = langgraph dev (as @@SERVICE_USER@@, bound to 127.0.0.1 — nginx
|
||||
# is the sole ingress; never binds 0.0.0.0).
|
||||
# ExecStartPost = seed_store.sh <env> -> reseeds team_settings + user_mappings
|
||||
# that the in-memory store loses on every restart.
|
||||
[Unit]
|
||||
Description=Open SWE stock LangGraph dev server (graphs + webapp, :@@PORT@@)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
RequiresMountsFor=/run/open-swe
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=@@SERVICE_USER@@
|
||||
Group=@@SERVICE_USER@@
|
||||
WorkingDirectory=@@APP_DIR@@
|
||||
|
||||
# No EnvironmentFile: the secret-bearing app .env (@@ENV_FILE@@) is loaded by
|
||||
# langgraph/dotenv (so the multiline GitHub App PEM never hits systemd's env
|
||||
# parser), and seed_store.sh reads the same .env directly (without sourcing it).
|
||||
#
|
||||
# ExecStartPre runs as root (`+`) so it can chown the tmpfs .env to the service
|
||||
# user; the env arg (@@OPENSWE_ENV@@) selects the SSM/Secrets prefix (T5 BOOT-01).
|
||||
ExecStartPre=+@@FETCH_CONFIG@@ @@OPENSWE_ENV@@
|
||||
# Bind 127.0.0.1 only — nginx proxies dashboard + webhooks; :@@PORT@@ is never
|
||||
# directly network-reachable (T5 OSWE-IAC-03).
|
||||
ExecStart=@@VENV@@/bin/langgraph dev --host 127.0.0.1 --port @@PORT@@ --no-browser --no-reload
|
||||
ExecStartPost=@@SEED_STORE@@ @@OPENSWE_ENV@@
|
||||
|
||||
# App logs to a file CloudWatch collects (30-day retention set in the CW config).
|
||||
StandardOutput=append:/var/log/open-swe/app.log
|
||||
StandardError=append:/var/log/open-swe/app.log
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=180
|
||||
|
||||
# Hardening — the box holds no durable state of its own.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/var/log/open-swe /var/www/open-swe /run/open-swe @@APP_DIR@@
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
145
deploy/ami/user-data.sh
Executable file
145
deploy/ami/user-data.sh
Executable file
|
|
@ -0,0 +1,145 @@
|
|||
#!/usr/bin/env bash
|
||||
# Open SWE EC2 user-data — PROVISIONING-ONLY (runs once, at first boot).
|
||||
#
|
||||
# This is the rationale for `userDataCausesReplacement: true` in CDK: user-data
|
||||
# does FIRST-BOOT provisioning, never durable runtime config. Editing it is a
|
||||
# deliberate instance replacement. Durable runtime config is fetched fresh on
|
||||
# every service start by deploy/seahaven/fetch-config.sh (ExecStartPre).
|
||||
#
|
||||
# The box holds NO durable state of its own:
|
||||
# - secrets/config -> Secrets Manager + SSM, materialized to a tmpfs .env at boot
|
||||
# - app artifact -> pulled from S3 (open-swe-<env>-assets) via the instance role
|
||||
# - store state -> reseeded by seed_store.sh (ExecStartPost) on every start
|
||||
# => there is no RETAIN volume to protect; replacement is tolerated. The EBS
|
||||
# discipline (snapshot root + wait state=completed BEFORE any replacing deploy)
|
||||
# is the safety net, not durable on-box state. See README "EBS discipline".
|
||||
#
|
||||
# Tokens (@@...@@) are substituted by CDK when it renders this script into the
|
||||
# launch template. region is read from IMDSv2 as a fallback.
|
||||
|
||||
set -euo pipefail
|
||||
exec > >(tee -a /var/log/open-swe-user-data.log) 2>&1
|
||||
echo "==> open-swe user-data start $(date -u +%FT%TZ)"
|
||||
|
||||
# --- CDK-rendered values -----------------------------------------------------
|
||||
# NOTE: CDK-substituted tokens use %%...%% (rendered by app-service.ts), DISTINCT
|
||||
# from the @@...@@ tokens this script seds into the baked systemd/nginx templates.
|
||||
# The two MUST NOT share a delimiter: a shared @@OPENSWE_ENV@@ / @@SERVER_NAME@@
|
||||
# let CDK clobber the sed PATTERN, leaving the unit's token unsubstituted.
|
||||
OPENSWE_ENV="%%OPENSWE_ENV%%" # dev | prod
|
||||
ASSETS_BUCKET="%%ASSETS_BUCKET%%" # open-swe-<env>-assets
|
||||
SERVER_NAME="%%SERVER_NAME%%" # openswe[-dev].seahaven.com
|
||||
ARTIFACT_PREFIX="%%ARTIFACT_PREFIX%%" # e.g. releases/latest
|
||||
|
||||
# --- fixed layout (must match provision.sh + templates) ----------------------
|
||||
SERVICE_USER="openswe"
|
||||
APP_DIR="/opt/open-swe/app"
|
||||
VENV="${APP_DIR}/.venv"
|
||||
WWW_ROOT="/var/www/open-swe"
|
||||
TEMPLATE_DIR="/opt/open-swe/templates"
|
||||
ENV_FILE="/run/open-swe/.env"
|
||||
PORT="2024"
|
||||
FETCH_CONFIG="${APP_DIR}/deploy/seahaven/fetch-config.sh"
|
||||
SEED_STORE="${APP_DIR}/deploy/seahaven/seed_store.sh"
|
||||
|
||||
# region from IMDSv2
|
||||
TOKEN="$(curl -fsS -X PUT "http://169.254.169.254/latest/api/token" \
|
||||
-H "X-aws-ec2-metadata-token-ttl-seconds: 300" || true)"
|
||||
AWS_REGION="$(curl -fsS -H "X-aws-ec2-metadata-token: ${TOKEN}" \
|
||||
http://169.254.169.254/latest/meta-data/placement/region || echo us-east-1)"
|
||||
export AWS_DEFAULT_REGION="$AWS_REGION"
|
||||
echo "env=${OPENSWE_ENV} region=${AWS_REGION} bucket=${ASSETS_BUCKET} host=${SERVER_NAME}"
|
||||
|
||||
# --- boot.env: non-secret pointers fetch-config.sh reads ---------------------
|
||||
install -d -o root -g root -m 0755 /etc/open-swe
|
||||
cat >/etc/open-swe/boot.env <<EOF
|
||||
OPENSWE_ENV=${OPENSWE_ENV}
|
||||
AWS_REGION=${AWS_REGION}
|
||||
ASSETS_BUCKET=${ASSETS_BUCKET}
|
||||
ARTIFACT_PREFIX=${ARTIFACT_PREFIX}
|
||||
ENV_FILE=${ENV_FILE}
|
||||
SECRETS_PREFIX=open-swe-${OPENSWE_ENV}
|
||||
SSM_PREFIX=/open-swe-${OPENSWE_ENV}
|
||||
EOF
|
||||
chmod 0644 /etc/open-swe/boot.env
|
||||
|
||||
# --- ensure the tmpfs for the materialized .env is mounted -------------------
|
||||
# (baked into /etc/fstab by the AMI; mount it now in case it isn't yet.)
|
||||
install -d -o root -g root -m 0755 /run/open-swe || true
|
||||
mountpoint -q /run/open-swe || mount /run/open-swe || mount -t tmpfs \
|
||||
-o rw,nosuid,nodev,noexec,mode=0700,uid=${SERVICE_USER},gid=${SERVICE_USER},size=8m \
|
||||
tmpfs /run/open-swe
|
||||
|
||||
# --- install the deploy script (single source of the app-deploy procedure) ---
|
||||
# deploy.sh (deploy/ami/deploy.sh) pulls the release from S3, builds the venv with
|
||||
# `uv sync`, and restarts the service. CDK base64-renders the file into the
|
||||
# %%DEPLOY_SH_B64%% token below so it is a normal reviewable repo file, not an
|
||||
# inline heredoc. The `open-swe-<env>-deploy` SSM document runs this same script
|
||||
# for every subsequent release.
|
||||
echo "==> install /opt/open-swe/bin/deploy.sh"
|
||||
install -d -o root -g root -m 0755 /opt/open-swe/bin
|
||||
base64 -d >/opt/open-swe/bin/deploy.sh <<'DEPLOY_SH_B64'
|
||||
%%DEPLOY_SH_B64%%
|
||||
DEPLOY_SH_B64
|
||||
chmod 0755 /opt/open-swe/bin/deploy.sh
|
||||
|
||||
# --- render + install the systemd unit ---------------------------------------
|
||||
echo "==> install systemd unit"
|
||||
sed \
|
||||
-e "s|@@SERVICE_USER@@|${SERVICE_USER}|g" \
|
||||
-e "s|@@APP_DIR@@|${APP_DIR}|g" \
|
||||
-e "s|@@VENV@@|${VENV}|g" \
|
||||
-e "s|@@PORT@@|${PORT}|g" \
|
||||
-e "s|@@ENV_FILE@@|${ENV_FILE}|g" \
|
||||
-e "s|@@OPENSWE_ENV@@|${OPENSWE_ENV}|g" \
|
||||
-e "s|@@FETCH_CONFIG@@|${FETCH_CONFIG}|g" \
|
||||
-e "s|@@SEED_STORE@@|${SEED_STORE}|g" \
|
||||
"${TEMPLATE_DIR}/open-swe.service" >/etc/systemd/system/open-swe.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# --- render + install the nginx site -----------------------------------------
|
||||
echo "==> install nginx site"
|
||||
sed \
|
||||
-e "s|@@SERVER_NAME@@|${SERVER_NAME}|g" \
|
||||
-e "s|@@WWW_ROOT@@|${WWW_ROOT}|g" \
|
||||
-e "s|@@BACKEND_ADDR@@|127.0.0.1:${PORT}|g" \
|
||||
"${TEMPLATE_DIR}/open-swe.nginx.conf" >/etc/nginx/sites-available/open-swe
|
||||
ln -sfn /etc/nginx/sites-available/open-swe /etc/nginx/sites-enabled/open-swe
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
nginx -t
|
||||
|
||||
# --- CloudWatch agent: 30-day log retention ----------------------------------
|
||||
echo "==> configure CloudWatch agent (30-day retention)"
|
||||
sed -e "s|@@OPENSWE_ENV@@|${OPENSWE_ENV}|g" \
|
||||
"${TEMPLATE_DIR}/amazon-cloudwatch-agent.json" \
|
||||
>/opt/aws/amazon-cloudwatch-agent/etc/open-swe-cw.json
|
||||
/opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
|
||||
-a fetch-config -m ec2 -s \
|
||||
-c file:/opt/aws/amazon-cloudwatch-agent/etc/open-swe-cw.json
|
||||
|
||||
# --- start nginx FIRST (the security boundary + health surface) --------------
|
||||
# NOTE: intentionally NO swapfile here. The 8 GB-swapfile OOM hack existed only
|
||||
# for the on-box Nitro SPA build, which now runs in GitHub Actions -> S3.
|
||||
# nginx is brought up BEFORE the app is deployed so the ALB target-group health
|
||||
# check (static `/healthz` -> 200) passes and the box is a healthy target even on
|
||||
# the very first boot, before any release is published. open-swe.service is
|
||||
# enabled (boot persistence) but STARTED by deploy.sh once the app is on disk.
|
||||
echo "==> start nginx"
|
||||
systemctl enable --now nginx
|
||||
systemctl reload nginx
|
||||
systemctl enable open-swe.service
|
||||
|
||||
# --- deploy the app (NON-FATAL on first boot) --------------------------------
|
||||
# deploy.sh pulls the release, builds the venv, and starts open-swe.service. On a
|
||||
# brand-new env no release exists yet, so this is allowed to fail WITHOUT aborting
|
||||
# user-data: nginx is already up (healthy target), and the first `build-artifacts`
|
||||
# run + `open-swe-<env>-deploy` SSM command will bring the app up. A failure here
|
||||
# is logged, not fatal.
|
||||
echo "==> initial app deploy (non-fatal if no release is published yet)"
|
||||
if /opt/open-swe/bin/deploy.sh; then
|
||||
echo "==> initial app deploy succeeded"
|
||||
else
|
||||
echo "==> no release yet (or deploy failed): open-swe.service deferred to the next SSM deploy"
|
||||
fi
|
||||
|
||||
echo "==> open-swe user-data done $(date -u +%FT%TZ)"
|
||||
118
deploy/seahaven/DEPLOYMENT.md
Normal file
118
deploy/seahaven/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
# Sea Haven — Open SWE self-hosted deployment
|
||||
|
||||
How this fork is deployed at Sea Haven. The runtime is the **stock LangGraph dev
|
||||
server** (not the Aegra path — see [Aegra](#aegra-deferred)). Internal addresses,
|
||||
ARNs, and account IDs are shown as `<PLACEHOLDERS>`; the real values live in the
|
||||
private IT docs (Confluence "AWS Architecture Map") — **do not commit them to this
|
||||
public fork.**
|
||||
|
||||
## Topology
|
||||
|
||||
```
|
||||
GitHub / Slack ──▶ hooks.seahavenind.com ──┐
|
||||
│ (public ALB :443, host+path rule)
|
||||
Browser ─────────▶ openswe.seahavenind.com ─┤
|
||||
▼
|
||||
AWS ALB ──(Site-to-Site VPN)──▶ on-prem VM
|
||||
├─ nginx :80 (dashboard SPA + /dashboard/api proxy)
|
||||
└─ langgraph dev :2024 (3+ graphs + FastAPI webapp)
|
||||
└─▶ LangSmith cloud sandbox (build/git/PR)
|
||||
```
|
||||
|
||||
- The VM is **internet-closed**; all inbound rides the existing ALB over the VPN.
|
||||
- **Webhooks** (`hooks.seahavenind.com`) → ALB listener rule scoped to `/webhooks/*`
|
||||
only → VM `:2024`. The unauthenticated LangGraph API (`/threads`, `/runs`,
|
||||
`/assistants`, `/store`) is never path-forwarded.
|
||||
- **Dashboard** (`openswe.seahavenind.com`) → ALB → VM `:80` (nginx). nginx is the
|
||||
security boundary: it serves the static SPA and proxies **only** `/dashboard/api/`
|
||||
to `:2024`; the agent API is not reachable through it.
|
||||
|
||||
## VM components
|
||||
|
||||
| Component | What |
|
||||
|---|---|
|
||||
| `langgraph dev` | systemd `open-swe.service` — `--host 0.0.0.0 --port 2024 --no-browser --no-reload`. In-memory runtime. |
|
||||
| Store seeding | `seed_store.sh` as `ExecStartPost` (re-seeds team settings + user mappings, which the in-memory store loses on restart). |
|
||||
| nginx | `nginx/openswe.conf` — SPA from `/var/www/openswe`, proxy `/dashboard/api/` → `:2024`. |
|
||||
| Postgres | present (was for the Aegra path); unused by the stock in-memory runtime. |
|
||||
| swap | 8 GB swapfile — **required**: the dashboard (`ui/`) Nitro build OOMs on an 8 GB box without it. |
|
||||
|
||||
### Models
|
||||
Model selection is **store-driven**, not env. `LLM_MODEL_ID` is effectively dead for
|
||||
runtime selection; the `team_settings/default` store doc wins (then per-user profile,
|
||||
then per-thread). Defaults seeded by `seed_store.sh`:
|
||||
- builder: `anthropic:claude-opus-4-8` (effort `high`)
|
||||
- reviewer (cross-family): `openai:gpt-5.5` (effort `high`) — `openai:gpt-4.1` is **not**
|
||||
in this fork's `SUPPORTED_MODELS` (`agent/dashboard/options.py`); a raw value is
|
||||
silently rewritten to gpt-5.5. Add it to `SUPPORTED_MODELS` first if you need 4.1.
|
||||
- `analyzer` graph is hardcoded to the code default and ignores team settings.
|
||||
|
||||
## Build & deploy the dashboard (`ui/`)
|
||||
|
||||
`ui/` is a **TanStack Start + Nitro** app (build with `bun`, not plain Vite):
|
||||
|
||||
```bash
|
||||
cd ui
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
export NODE_OPTIONS=--max-old-space-size=6144 # + the 8 GB swapfile, or the build OOMs
|
||||
bun install
|
||||
bun run build # -> .output/public (static SPA, _shell.html)
|
||||
sudo cp -r .output/public/. /var/www/openswe/ # served by nginx
|
||||
```
|
||||
|
||||
Served as a static SPA (per `ui/vercel.json`); the Nitro `.output/server` is unused.
|
||||
|
||||
## Install / wire-up checklist
|
||||
|
||||
1. App config in `.env` (gitignored — never commit): LLM keys, GitHub App creds,
|
||||
`LANGSMITH_API_KEY*` + `DEFAULT_SANDBOX_SNAPSHOT_ID` (`SANDBOX_TYPE=langsmith` — the
|
||||
only sandbox provider with working in-sandbox git/gh auth), `LANGGRAPH_URL=http://127.0.0.1:2024`,
|
||||
dashboard vars (`DASHBOARD_JWT_SECRET`, `CONFIGURED_ADMINS`, `DASHBOARD_*_URL=https://openswe.seahavenind.com`).
|
||||
2. `systemd/open-swe.service` → `/etc/systemd/system/`, `seed_store.sh` on the VM with
|
||||
`OPENSWE_*` env exported (owner login/email, default repo, model ids).
|
||||
3. `nginx/openswe.conf` → `/etc/nginx/sites-available/openswe`, symlink into
|
||||
`sites-enabled`, remove the default site, `nginx -t && systemctl reload nginx`.
|
||||
4. AWS (real IDs in Confluence): IP target groups → `<VM_LAN_IP>:2024` and `:80`;
|
||||
ALB SG **egress** rules to those ports (the ALB SG is allow-listed — health checks
|
||||
time out without them); `:443` listener rules for the two hostnames; Route53 ALIAS
|
||||
records → ALB. Webhook rule must stay path-scoped to `/webhooks/*`.
|
||||
5. GitHub App: webhook URL `https://hooks.seahavenind.com/webhooks/github`; subscribe to
|
||||
the events the install guide lists (Issue comment, PR review×2, check_run/suite,
|
||||
workflow_run, status) — add **Issues** too if you want issue-title/body triggers.
|
||||
6. **GitHub App OAuth callback (manual, UI-only — not API-settable):**
|
||||
`https://openswe.seahavenind.com/dashboard/api/auth/callback` — without it, dashboard
|
||||
login fails with a `redirect_uri` mismatch.
|
||||
|
||||
## Triggering
|
||||
|
||||
Start a task by mentioning **`@openswe`** in a GitHub issue comment (the documented
|
||||
intake — the `open-swe` *label* path needs the `Issues` event subscription). The
|
||||
commenter must have a `user_mappings` entry or the run is skipped.
|
||||
|
||||
## AWS variant — env-sourced config (.env from Secrets Manager + SSM)
|
||||
|
||||
On the AWS lift-and-shift, the `.env` is **not** staged by hand. A boot hook pulls
|
||||
config from AWS via the EC2 instance role and materializes a root-only `.env` on a
|
||||
tmpfs before the service starts. Same stock runtime; only how `.env` is produced
|
||||
changes.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `fetch-config.sh <dev\|prod>` | `ExecStartPre=+` hook. Reads all SSM params `/open-swe-<env>/*` + all secrets `open-swe-<env>/*`, FAIL-FAST on any missing required var, writes `/run/open-swe/.env` (tmpfs, root:root 0600), symlinks `<APP_DIR>/.env` → it. Forces `DEFAULT_REPO_OWNER`=`Sea-Haven-Industries` (never upstream `langchain-ai`). |
|
||||
| `seed_store.sh <dev\|prod>` | `ExecStartPost` hook (unchanged role). Now sources the materialized `.env`, so `default_repo`/models/user-mappings come from AWS config instead of hardcoded `OPENSWE_*`. Still re-seeds the in-memory store on **every** restart. |
|
||||
| `ROTATION.md` | How to rotate any secret/param (update AWS → `systemctl restart`) + per-secret notes (`TOKEN_ENCRYPTION_KEY` overlap list, GitHub App PEM, webhook signing secrets). |
|
||||
|
||||
Because the `.env` is root-only, the AWS `open-swe.service` runs **as root** (the
|
||||
on-prem unit's `User=adam` cannot read it). See the header of `fetch-config.sh` for
|
||||
the exact unit snippet and the tmpfs / `RUN_DEDICATED_TMPFS` options. Naming/secret
|
||||
placement follows the T9 inventory: 29 secrets → Secrets Manager `open-swe-<env>/*`,
|
||||
53 config → SSM `/open-swe-<env>/*`, each keyed by the literal env-var name.
|
||||
|
||||
## Aegra (deferred)
|
||||
|
||||
`aegra/aegra.json` + `aegra/aegra_entry.py` are the self-hosted-runtime alternative
|
||||
(Apache-2.0, avoids the LangGraph-Platform Elastic license). Not active on the stock
|
||||
deployment. To use: place both at the repo root, run `aegra serve` (:2026), and point
|
||||
`LANGGRAPH_URL` at `:2026`. Aegra gives a Postgres-backed durable store/checkpointer,
|
||||
which removes the need for `seed_store.sh` and survives restarts (paused HITL
|
||||
interrupts persist).
|
||||
92
deploy/seahaven/ROTATION.md
Normal file
92
deploy/seahaven/ROTATION.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# Sea Haven — Open SWE secret & config rotation
|
||||
|
||||
How secrets and config reach the running app, and how to rotate either one.
|
||||
|
||||
## How values flow at boot
|
||||
|
||||
```
|
||||
AWS Secrets Manager open-swe-<env>/* ─┐
|
||||
AWS SSM Param Store /open-swe-<env>/* ─┤── fetch-config.sh ──▶ tmpfs /run/open-swe/.env (root:root 0600)
|
||||
│ (systemd ExecStartPre=+, EC2 role) │
|
||||
▼ ▼
|
||||
FAIL-FAST if a app symlink <APP_DIR>/.env
|
||||
required var is empty python-dotenv reads at import
|
||||
```
|
||||
|
||||
The app reads `.env` **once, at import**. There is no hot-reload of secrets.
|
||||
Therefore the rotation contract is always the same two steps:
|
||||
|
||||
> **Rotation = (1) update the value in Secrets Manager / SSM, then (2) restart the
|
||||
> service** so `fetch-config.sh` re-materializes the `.env`.
|
||||
|
||||
```bash
|
||||
# after updating a secret/param in AWS:
|
||||
sudo systemctl restart open-swe.service
|
||||
# ExecStartPre=+ -> fetch-config.sh re-pulls + rewrites the tmpfs .env (fail-fast)
|
||||
# ExecStartPost -> seed_store.sh re-seeds the in-memory store (team_settings + user_mappings)
|
||||
```
|
||||
|
||||
There is **no zero-downtime path for most secrets** on the stock in-memory
|
||||
runtime — a restart is required and it also wipes the in-memory store (re-seeded
|
||||
by `seed_store.sh` automatically). The one secret built for zero-downtime overlap
|
||||
is `TOKEN_ENCRYPTION_KEY` (see below), but even it needs the restart to load the
|
||||
new key list.
|
||||
|
||||
## Rotating a secret (Secrets Manager)
|
||||
|
||||
```bash
|
||||
ENV=prod # or dev
|
||||
NAME=DASHBOARD_JWT_SECRET
|
||||
aws secretsmanager put-secret-value \
|
||||
--secret-id "open-swe-${ENV}/${NAME}" \
|
||||
--secret-string 'NEW_VALUE' \
|
||||
--region us-east-1
|
||||
sudo systemctl restart open-swe.service # on the box
|
||||
```
|
||||
|
||||
(`update-secret`/`put-secret-value` both create a new version; the boot hook
|
||||
always reads `AWSCURRENT`.)
|
||||
|
||||
## Rotating a config param (SSM)
|
||||
|
||||
```bash
|
||||
aws ssm put-parameter --overwrite \
|
||||
--name "/open-swe-${ENV}/DASHBOARD_BASE_URL" \
|
||||
--type String --value 'https://openswe.seahaven.com' \
|
||||
--region us-east-1
|
||||
sudo systemctl restart open-swe.service
|
||||
```
|
||||
|
||||
## Per-secret rotation notes
|
||||
|
||||
| Secret | Rotation notes |
|
||||
|---|---|
|
||||
| **TOKEN_ENCRYPTION_KEY** | Fernet key(s). Supports a **comma/newline-separated list** (`agent/encryption.py`) for zero-downtime key rotation: prepend the NEW key, keep the OLD key(s) in the list. New data is encrypted with the first key; old data still decrypts with the trailing keys. After all encrypted-at-rest tokens (per-user GitHub OAuth tokens in thread metadata) have been re-encrypted/expired, drop the old key. Store the list as one secret value; `fetch-config.sh` writes it verbatim. **Never** rotate to a single new key in one step or every existing encrypted token becomes undecryptable. |
|
||||
| **GITHUB_APP_PRIVATE_KEY** | Multiline PEM. Generate a new private key in the GitHub App settings (you may have **two active keys** during overlap), put the new PEM into the secret, restart, verify install-token minting + a webhook delivery, then delete the old key in GitHub. `fetch-config.sh` writes the PEM as a double-quoted multiline value (python-dotenv-safe); paste the full `-----BEGIN…-----END-----` block including newlines. |
|
||||
| **GITHUB_WEBHOOK_SECRET** | Webhook HMAC. GitHub allows only **one** webhook secret per App, so this is a brief-break rotation: update the secret in AWS **and** the GitHub App webhook config, restart. Deliveries signed with the old secret during the gap will 401 (GitHub auto-redelivers). Required in **prod** (fail-fast). |
|
||||
| **SLACK_SIGNING_SECRET** | Slack request-signature secret. Rotate in the Slack app config and AWS together, restart. Required in **prod** (fail-fast). A stale value silently 401s `url_verification`/events until restart (known gotcha). |
|
||||
| **LINEAR_WEBHOOK_SECRET** | Linear webhook signature. Required in prod only when the Linear integration is wired (`LINEAR_API_KEY` present). Rotate in Linear + AWS together, restart. |
|
||||
| **SLACK_CLIENT_SECRET / GITHUB_APP_CLIENT_SECRET** | OAuth client secrets (dashboard login / Slack OAuth). Rotate in the provider console + AWS, restart. Existing dashboard sessions are JWT-signed by `DASHBOARD_JWT_SECRET`, not these, so they survive. |
|
||||
| **DASHBOARD_JWT_SECRET** | Signs dashboard session cookies. Rotating **invalidates all active sessions** (users re-login). Hard-required (RuntimeError if empty). No overlap list — single value. |
|
||||
| **Model provider keys** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GOOGLE_API_KEY`, `GROQ_API_KEY`, `FIREWORKS_API_KEY`) | Standard API-key rotation: issue new key, update AWS, restart, revoke old. The **active** provider keys (whatever `team_settings/default` seeds — currently `ANTHROPIC_API_KEY` + `OPENAI_API_KEY`) are fail-fast-required; keep `REQUIRED_PROVIDER_KEYS` in sync if you change the seeded models. |
|
||||
| **LANGSMITH_API_KEY_PROD** | LangSmith key powering the `langsmith` sandbox (the only provider with working in-sandbox git/gh auth). Required when `SANDBOX_TYPE=langsmith` (fail-fast). Rotate in LangSmith + AWS, restart; existing sandboxes keep their already-injected proxy token until recycled. |
|
||||
| **SLACK_BOT_TOKEN / LINEAR_API_KEY / GITHUB_PAT / EXA_API_KEY / DAYTONA_API_KEY / RUNLOOP_API_KEY / CORRIDOR_* / USER_ID_API_KEY_MAP / X_SERVICE_AUTH_JWT_SECRET** | Plain API-token rotation: update AWS, restart, revoke old at the provider. None support an overlap list. |
|
||||
|
||||
## Fail-fast safety
|
||||
|
||||
`fetch-config.sh` refuses to write the `.env` (exit 1) if any required var is
|
||||
empty after a rotation — so a botched rotation (e.g. an empty `put-secret-value`)
|
||||
stops the service at `ExecStartPre` instead of starting it with a partial `.env`.
|
||||
The missing variable **names** are printed to the journal (values never are):
|
||||
|
||||
```bash
|
||||
sudo journalctl -u open-swe.service -b | grep fetch-config
|
||||
```
|
||||
|
||||
Required set enforced: `DASHBOARD_JWT_SECRET`, `TOKEN_ENCRYPTION_KEY`,
|
||||
`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID`,
|
||||
`GITHUB_APP_CLIENT_ID`, `GITHUB_APP_CLIENT_SECRET`, the active provider keys
|
||||
(`REQUIRED_PROVIDER_KEYS`, default `ANTHROPIC_API_KEY,OPENAI_API_KEY`), the
|
||||
sandbox key for `SANDBOX_TYPE` (`langsmith` ⇒ `LANGSMITH_API_KEY_PROD` +
|
||||
`DEFAULT_SANDBOX_SNAPSHOT_ID`), and — in **prod** — `GITHUB_WEBHOOK_SECRET`,
|
||||
`SLACK_SIGNING_SECRET` (plus `LINEAR_WEBHOOK_SECRET` when Linear is wired).
|
||||
11
deploy/seahaven/aegra/aegra.json
Normal file
11
deploy/seahaven/aegra/aegra.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./aegra_entry.py:agent_graph",
|
||||
"reviewer": "./aegra_entry.py:reviewer_graph",
|
||||
"analyzer": "./aegra_entry.py:analyzer_graph"
|
||||
},
|
||||
"http": {
|
||||
"app": "./aegra_entry.py:webapp_app"
|
||||
}
|
||||
}
|
||||
19
deploy/seahaven/aegra/aegra_entry.py
Normal file
19
deploy/seahaven/aegra/aegra_entry.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Aegra entrypoint for Open SWE graphs.
|
||||
|
||||
Aegra loads graph files standalone via importlib.spec_from_file_location, which
|
||||
gives them a synthetic module name and breaks agent/*.py's package-relative
|
||||
imports (e.g. `from .dashboard.admin import ...`). Re-exporting the graphs here
|
||||
through the installed ``agent`` package (absolute imports) restores correct
|
||||
``__package__`` resolution, so the relative imports inside the agent modules work.
|
||||
"""
|
||||
|
||||
from agent.analyzer import traced_analyzer as analyzer_graph
|
||||
from agent.reviewer import traced_reviewer_agent as reviewer_graph
|
||||
from agent.server import traced_agent as agent_graph
|
||||
|
||||
# Open SWE's FastAPI webapp (GitHub/Slack webhooks + dashboard API), mounted by
|
||||
# Aegra via the "http" key in aegra.json. Absolute import for the same reason as
|
||||
# the graphs above (relative imports break under Aegra's standalone file loader).
|
||||
from agent.webapp import app as webapp_app
|
||||
|
||||
__all__ = ["agent_graph", "reviewer_graph", "analyzer_graph", "webapp_app"]
|
||||
362
deploy/seahaven/fetch-config.sh
Executable file
362
deploy/seahaven/fetch-config.sh
Executable file
|
|
@ -0,0 +1,362 @@
|
|||
#!/usr/bin/env bash
|
||||
# fetch-config.sh — AWS-sourced boot hook that materializes the app's .env.
|
||||
#
|
||||
# The stock `langgraph dev` runtime + the Open SWE app read a plain `.env` from
|
||||
# the app working directory (python-dotenv). On the AWS lift-and-shift we do NOT
|
||||
# commit a .env; instead every non-sensitive value lives in SSM Parameter Store
|
||||
# (`/open-swe-<env>/*`) and every secret lives in AWS Secrets Manager
|
||||
# (`open-swe-<env>/*`). This hook is run by systemd BEFORE the service starts; it
|
||||
# pulls both sources via the EC2 instance role (no static keys), assembles a
|
||||
# single .env on a tmpfs, and writes it owned by the unprivileged service user
|
||||
# `chmod 600` (T5 SC-01: the privileged pre-hook materializes the secret; the app
|
||||
# itself then runs as that NON-root service user, not root).
|
||||
#
|
||||
# It is intentionally FAIL-FAST: if any required secret/param is missing or empty
|
||||
# it prints the offending variable NAMES (never values) and exits 1, so the
|
||||
# service never starts with a partial .env.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# Naming contract (source of truth: T9 env/secret/config inventory)
|
||||
# SSM /open-swe-<env>/<ENV_VAR_NAME> -> exported as ENV_VAR_NAME
|
||||
# Secrets open-swe-<env>/<ENV_VAR_NAME> -> exported as ENV_VAR_NAME
|
||||
# i.e. the last path segment IS the literal environment-variable name. This is a
|
||||
# deliberate (documented) deviation from the handbook's kebab-case value-name
|
||||
# example (`my-stack/slack-signing`): a .env materializer needs a lossless,
|
||||
# unambiguous round-trip from store key -> env var, and the env var name is the
|
||||
# only key that guarantees that. The `open-swe-<env>` stack prefix still follows
|
||||
# kebab-case per naming-conventions.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Wiring into systemd (AWS EC2 variant):
|
||||
# The unit runs as the unprivileged service user (User=openswe). ONLY the
|
||||
# ExecStartPre pre-hook runs as root (the `+` prefix) so it can pull from AWS,
|
||||
# write the tmpfs .env, and chown it to the service user. The app (ExecStart)
|
||||
# and the seeder (ExecStartPost) then run as openswe and read the openswe-owned
|
||||
# 0600 .env — the agent never runs as root (T5 SC-01). Pass the env as the
|
||||
# positional arg (T5 BOOT-01):
|
||||
#
|
||||
# [Service]
|
||||
# User=openswe
|
||||
# Group=openswe
|
||||
# Environment=ENV_DIR=/run/open-swe SERVICE_USER=openswe
|
||||
# # ExecStartPre runs as root (+) so it can chown the .env to the service user.
|
||||
# ExecStartPre=+/opt/open-swe/deploy/seahaven/fetch-config.sh prod
|
||||
# ExecStart=/opt/open-swe/.venv/bin/langgraph dev --host 127.0.0.1 --port 2024 \
|
||||
# --no-browser --no-reload
|
||||
# ExecStartPost=/opt/open-swe/deploy/seahaven/seed_store.sh prod
|
||||
#
|
||||
# tmpfs: /run is already a tmpfs on systemd hosts, so ENV_DIR=/run/open-swe is
|
||||
# tmpfs-backed by default (the .env never touches disk). Set RUN_DEDICATED_TMPFS=1
|
||||
# to mount a private tmpfs at ENV_DIR instead. The app's CWD `.env` is a symlink
|
||||
# into ENV_DIR (created idempotently below), so python-dotenv finds it unchanged.
|
||||
#
|
||||
# Idempotent, re-runnable on every (re)start. No secret is ever echoed.
|
||||
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
# --- Inputs ------------------------------------------------------------------
|
||||
ENV="${1:-${OPENSWE_ENV:-}}"
|
||||
case "$ENV" in
|
||||
dev | prod) ;;
|
||||
*)
|
||||
echo "fetch-config: ENV must be 'dev' or 'prod' (got '${ENV:-<empty>}')" >&2
|
||||
echo "usage: fetch-config.sh <dev|prod> (or set OPENSWE_ENV)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
REGION="${AWS_REGION:-${AWS_DEFAULT_REGION:-us-east-1}}"
|
||||
SSM_PREFIX="/open-swe-${ENV}/"
|
||||
SECRET_PREFIX="open-swe-${ENV}/"
|
||||
|
||||
ENV_DIR="${ENV_DIR:-/run/open-swe}" # tmpfs-backed (/run) by default
|
||||
ENV_FILE="${ENV_DIR}/.env"
|
||||
APP_DIR="${APP_DIR:-/opt/open-swe}" # where the app + its CWD .env live
|
||||
APP_ENV_LINK="${APP_DIR}/.env" # symlink -> ENV_FILE
|
||||
|
||||
# The unprivileged service user that runs the app and OWNS the .env (T5 SC-01).
|
||||
# fetch-config runs as root (ExecStartPre=+) only to chown the secret to it.
|
||||
SERVICE_USER="${SERVICE_USER:-openswe}"
|
||||
SERVICE_GROUP="${SERVICE_GROUP:-${SERVICE_USER}}"
|
||||
|
||||
# Sea Haven owner guard inputs (applied after the store is read, below). The owner
|
||||
# normally comes from SSM /open-swe-<env>/DEFAULT_REPO_OWNER; OPENSWE_REPO_OWNER is an
|
||||
# explicit operator override that wins over the store. FORBIDDEN = the upstream org
|
||||
# the fork must never target; SAFE = the fallback when the resolved owner is blank or
|
||||
# forbidden. (Upper/lower + whitespace are normalized before the guard check.)
|
||||
OPENSWE_REPO_OWNER="${OPENSWE_REPO_OWNER:-}"
|
||||
FORBIDDEN_REPO_OWNER="langchain-ai"
|
||||
# Fallback org when the resolved owner is blank/forbidden — PER-ENV (mirrors the
|
||||
# iacManagedSsm owner) so a dev box can NEVER fall back into the real Sea Haven org;
|
||||
# it stays isolated in its own dev org. Defends the blank/upstream cases in-env.
|
||||
case "$ENV" in
|
||||
dev) SAFE_REPO_OWNER="seahaven-open-swe-dev" ;;
|
||||
*) SAFE_REPO_OWNER="Sea-Haven-Industries" ;;
|
||||
esac
|
||||
|
||||
for bin in aws jq; do
|
||||
command -v "$bin" >/dev/null 2>&1 || { echo "fetch-config: '$bin' not found on PATH" >&2; exit 3; }
|
||||
done
|
||||
|
||||
log() { echo "fetch-config[$ENV]: $*"; } # NAMES/counts only — never values
|
||||
b64d() { base64 --decode; } # GNU coreutils on the EC2 host
|
||||
|
||||
# Accept a store key into VARS iff it is a valid env-var identifier and not a
|
||||
# duplicate. Rejects non-identifier names (T5 SH-INJ-002 / set -e DoS hardening)
|
||||
# and flat-namespace collisions (T5 SSM-05). $3 = source label for logs.
|
||||
accept_var() {
|
||||
local key="$1" value="$2" src="$3"
|
||||
if ! [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||
log "WARNING: skipping ${src} key with non-identifier name (rejected)"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${VARS[$key]+set}" ]; then
|
||||
echo "fetch-config[$ENV]: FAIL-FAST — duplicate key '${key}' from ${src} (flat-namespace collision)" >&2
|
||||
exit 1
|
||||
fi
|
||||
VARS["$key"]="$value"
|
||||
}
|
||||
|
||||
# --- tmpfs ------------------------------------------------------------------
|
||||
mkdir -p "$ENV_DIR"
|
||||
# Owned by the service user so the unprivileged app can traverse it (T5 SC-01).
|
||||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "$ENV_DIR" 2>/dev/null || true
|
||||
chmod 700 "$ENV_DIR"
|
||||
if [ "${RUN_DEDICATED_TMPFS:-0}" = "1" ] && ! mountpoint -q "$ENV_DIR"; then
|
||||
mount -t tmpfs -o nosuid,nodev,noexec,mode=0700,size=4m tmpfs "$ENV_DIR"
|
||||
log "mounted dedicated tmpfs at $ENV_DIR"
|
||||
fi
|
||||
|
||||
# --- Collect values into an associative array --------------------------------
|
||||
declare -A VARS=()
|
||||
|
||||
# 1) SSM Parameter Store (non-sensitive config). NOT --recursive: the contract is
|
||||
# a FLAT namespace /open-swe-<env>/<VAR>, so a non-recursive list returns exactly
|
||||
# those keys and cannot collapse two nested paths onto one name (T5 SSM-05). aws
|
||||
# CLI v2 auto-paginates NextToken.
|
||||
log "reading SSM params under ${SSM_PREFIX} ..."
|
||||
ssm_json="$(
|
||||
aws ssm get-parameters-by-path \
|
||||
--path "$SSM_PREFIX" \
|
||||
--with-decryption \
|
||||
--region "$REGION" \
|
||||
--no-cli-pager \
|
||||
--output json
|
||||
)"
|
||||
# Records are base64-encoded (name<TAB>value) so values with spaces/newlines/tabs
|
||||
# survive the line-based read intact.
|
||||
ssm_count=0
|
||||
while IFS=$'\t' read -r nb vb; do
|
||||
[ -n "$nb" ] || continue
|
||||
name="$(printf '%s' "$nb" | b64d)"
|
||||
value="$(printf '%s' "$vb" | b64d; printf 'x')"; value="${value%x}"
|
||||
key="${name##*/}" # strip /open-swe-<env>/ prefix
|
||||
[ -n "$key" ] || continue
|
||||
accept_var "$key" "$value" "SSM"
|
||||
ssm_count=$((ssm_count + 1))
|
||||
done < <(jq -r '.Parameters[] | (.Name|@base64) + "\t" + (.Value|@base64)' <<<"$ssm_json")
|
||||
log "loaded ${ssm_count} config param(s) from SSM"
|
||||
|
||||
# 2) Secrets Manager (sensitive values). We request secrets by EXPLICIT id
|
||||
# (`batch-get-secret-value --secret-id-list ...`) rather than a name-prefix
|
||||
# `--filters` collection scan (OSWE-IAC-SECRETS-LIST-01). Two wins:
|
||||
# (a) Least privilege — an explicit id list lets the instance role scope
|
||||
# BatchGetSecretValue to the per-secret ARN prefix and DROP the account-wide
|
||||
# `secretsmanager:ListSecrets` grant that a filtered scan unavoidably forces
|
||||
# (ListSecrets has no resource-level scoping). A filtered batch call also only
|
||||
# authorizes against `*`; an id-list call authorizes per-secret ARN.
|
||||
# (b) Deterministic set — the value set is the fixed SECRET_VARS shells created by
|
||||
# the CDK ConfigStore, so we no longer depend on a list scan returning every
|
||||
# page. Value-less shells and ids with no current value come back in the
|
||||
# response `.Errors[]` (ResourceNotFound), never in `.SecretValues[]`, so a
|
||||
# genuinely-missing REQUIRED secret is still caught by the FAIL-FAST check
|
||||
# below — an absent optional secret is simply skipped.
|
||||
# `--secret-id-list` is capped at 20 ids per call, so we chunk it. `--no-cli-pager`
|
||||
# disables only the OUTPUT pager. Each record is base64(name<TAB>value).
|
||||
#
|
||||
# SOURCE OF TRUTH for this list: infra/lib/constructs/config-store.ts `SECRET_VARS`.
|
||||
# Keep the two in lockstep — a new secret shell created there must be added here or it
|
||||
# will never be fetched into the .env.
|
||||
SECRET_VARS=(
|
||||
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
|
||||
)
|
||||
|
||||
batch_get_secrets_tsv() {
|
||||
local -a ids=()
|
||||
local v
|
||||
for v in "${SECRET_VARS[@]}"; do ids+=("${SECRET_PREFIX}${v}"); done
|
||||
|
||||
local i page
|
||||
local -a chunk
|
||||
for ((i = 0; i < ${#ids[@]}; i += 20)); do
|
||||
chunk=("${ids[@]:i:20}")
|
||||
# Capture the response into a variable FIRST so a non-zero `aws` exit (throttle,
|
||||
# AccessDenied, KMS DecryptionFailure) aborts under set -e instead of being
|
||||
# silently swallowed — then we'd FAIL-FAST below as "missing secret" with a wrong
|
||||
# root cause. (Value-less / absent shells come back in .Errors[], not .SecretValues[].)
|
||||
page="$(aws secretsmanager batch-get-secret-value \
|
||||
--secret-id-list "${chunk[@]}" \
|
||||
--region "$REGION" --no-cli-pager --output json)"
|
||||
printf '%s' "$page" \
|
||||
| jq -r '.SecretValues[] | select(.SecretString != null) | (.Name|@base64) + "\t" + (.SecretString|@base64)'
|
||||
done
|
||||
}
|
||||
|
||||
log "reading secrets under ${SECRET_PREFIX} ..."
|
||||
secret_count=0
|
||||
# Capture into a variable (NOT `done < <(...)` process substitution) so a non-zero
|
||||
# exit from batch_get_secrets_tsv propagates under set -e — process substitution hides
|
||||
# the producer's exit status from the parent shell, which would let a failed AWS call
|
||||
# fall through to a misleading "missing required var" FAIL-FAST. Mirrors the SSM read.
|
||||
secrets_tsv="$(batch_get_secrets_tsv)"
|
||||
while IFS=$'\t' read -r nb vb; do
|
||||
[ -n "$nb" ] || continue
|
||||
name="$(printf '%s' "$nb" | b64d)"
|
||||
case "$name" in
|
||||
"${SECRET_PREFIX}"*) ;; # defensive: exact-prefix only
|
||||
*) continue ;;
|
||||
esac
|
||||
value="$(printf '%s' "$vb" | b64d; printf 'x')"; value="${value%x}"
|
||||
key="${name##*/}"
|
||||
[ -n "$key" ] || continue
|
||||
accept_var "$key" "$value" "Secrets"
|
||||
secret_count=$((secret_count + 1))
|
||||
done <<<"$secrets_tsv"
|
||||
log "loaded ${secret_count} secret(s) from Secrets Manager"
|
||||
|
||||
# --- Sea Haven DEFAULT_REPO_OWNER guard --------------------------------------
|
||||
# OSWE-OWNER-04 (revised for multi-org): HONOR the configured owner — the
|
||||
# OPENSWE_REPO_OWNER env override if set, else the store value — so per-env orgs
|
||||
# work (dev = seahaven-open-swe-dev, prod = Sea-Haven-Industries). But GUARD the two
|
||||
# values that must NEVER reach the agent: blank, and the upstream 'langchain-ai' org
|
||||
# (the fork's origin). Either falls back to the Sea Haven org so a stale/blank/mis-set
|
||||
# value can never point the agent upstream. Comparison is case- and whitespace-
|
||||
# insensitive. The POSITIVE org allowlist is enforced by the app (ALLOWED_GITHUB_ORGS).
|
||||
resolved_owner="${OPENSWE_REPO_OWNER:-${VARS[DEFAULT_REPO_OWNER]:-}}"
|
||||
# Normalize for the guard CHECK ONLY (the original value is what gets stored when
|
||||
# allowed): lowercase, strip whitespace, take the FIRST path segment so a value like
|
||||
# 'langchain-ai/open-swe' still trips the guard, and drop dots (GitHub owners contain
|
||||
# none) so 'langchain-ai.' can't slip past. Homoglyph/unicode variants are out of scope
|
||||
# here — the owner comes from admin-written SSM/IaC, not attacker-controlled input.
|
||||
norm_owner="$(printf '%s' "$resolved_owner" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||||
norm_owner="${norm_owner%%/*}"
|
||||
norm_owner="${norm_owner//./}"
|
||||
case "$norm_owner" in
|
||||
"" | "$FORBIDDEN_REPO_OWNER")
|
||||
log "WARNING: DEFAULT_REPO_OWNER ('${resolved_owner:-<blank>}') is blank or the upstream org -> forcing '${SAFE_REPO_OWNER}'"
|
||||
resolved_owner="$SAFE_REPO_OWNER"
|
||||
;;
|
||||
esac
|
||||
VARS[DEFAULT_REPO_OWNER]="$resolved_owner"
|
||||
|
||||
# --- FAIL-FAST: required vars -------------------------------------------------
|
||||
# Hard-required regardless of mode:
|
||||
required=(
|
||||
DASHBOARD_JWT_SECRET # RuntimeError on startup if missing (oauth.py)
|
||||
TOKEN_ENCRYPTION_KEY # Fernet key(s); decrypts per-user GitHub tokens
|
||||
)
|
||||
# NOTE: the GitHub App is NOT created/duplicated for dev — only prod owns the
|
||||
# (single, shared) GitHub App + Slack app. So the GitHub App quintet + Slack +
|
||||
# webhook-signing secrets are required for PROD only (see the prod block below).
|
||||
# Dev boots without them: it has no GitHub-App/Slack/webhook integration — it is a
|
||||
# deployment-validation env (boot/health/boundary), not a live-triggered agent.
|
||||
|
||||
# Active model-provider key(s): model selection is store-driven (team_settings),
|
||||
# so fetch-config cannot infer it from .env. Default to the seeded cross-family
|
||||
# pair (anthropic builder + openai reviewer). Override with a comma list.
|
||||
IFS=',' read -r -a provider_keys <<<"${REQUIRED_PROVIDER_KEYS:-ANTHROPIC_API_KEY,OPENAI_API_KEY}"
|
||||
for k in "${provider_keys[@]}"; do
|
||||
k="${k//[[:space:]]/}"
|
||||
[ -n "$k" ] && required+=("$k")
|
||||
done
|
||||
|
||||
# Sandbox provider key(s) — depends on SANDBOX_TYPE (default langsmith).
|
||||
sandbox_type="${VARS[SANDBOX_TYPE]:-langsmith}"
|
||||
case "$sandbox_type" in
|
||||
langsmith) required+=(LANGSMITH_API_KEY_PROD DEFAULT_SANDBOX_SNAPSHOT_ID) ;;
|
||||
daytona) required+=(DAYTONA_API_KEY) ;;
|
||||
runloop) required+=(RUNLOOP_API_KEY) ;;
|
||||
modal | local) ;; # no key required
|
||||
*) log "WARNING: unknown SANDBOX_TYPE='${sandbox_type}' — not enforcing a sandbox key" ;;
|
||||
esac
|
||||
|
||||
# Prod-only: the GitHub App (installation-token minting + dashboard OAuth) and the
|
||||
# webhook-signing secrets. Dev has no GitHub/Slack app, so none of these are
|
||||
# required there; prod owns the single shared app and must have all of them.
|
||||
if [ "$ENV" = "prod" ]; then
|
||||
required+=(
|
||||
GITHUB_APP_ID # GitHub App trio (installation-token minting) ...
|
||||
GITHUB_APP_PRIVATE_KEY # ... multiline PEM ...
|
||||
GITHUB_APP_INSTALLATION_ID # ... used by utils/github_app.py
|
||||
GITHUB_APP_CLIENT_ID # dashboard OAuth login
|
||||
GITHUB_APP_CLIENT_SECRET # dashboard OAuth login
|
||||
GITHUB_WEBHOOK_SECRET # webhook signature verification
|
||||
SLACK_SIGNING_SECRET # Slack webhook signature verification
|
||||
)
|
||||
if [ -n "${VARS[LINEAR_API_KEY]:-}" ] && [ "${OPENSWE_REQUIRE_LINEAR:-1}" = "1" ]; then
|
||||
required+=(LINEAR_WEBHOOK_SECRET)
|
||||
fi
|
||||
fi
|
||||
|
||||
missing=()
|
||||
for k in "${required[@]}"; do
|
||||
[ -n "${VARS[$k]:-}" ] || missing+=("$k")
|
||||
done
|
||||
# de-dup the names for a clean report
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
mapfile -t missing < <(printf '%s\n' "${missing[@]}" | sort -u)
|
||||
echo "fetch-config[$ENV]: FAIL-FAST — ${#missing[@]} required var(s) missing/empty:" >&2
|
||||
printf ' - %s\n' "${missing[@]}" >&2
|
||||
echo "fetch-config[$ENV]: refusing to write a partial .env; service will not start." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Write the .env atomically (root-only on tmpfs) --------------------------
|
||||
# python-dotenv reads double-quoted values (incl. multiline PEMs). Its decoder
|
||||
# unescapes ONLY backslash and double-quote (\\ -> \, \" -> "); it does NOT honor
|
||||
# \$ or \` escapes, so escaping those would leave a spurious backslash. Escape
|
||||
# exactly backslash then double-quote — real newlines stay literal (multiline OK).
|
||||
# (Caveat: python-dotenv interpolates a literal `${VAR}` substring; the secret
|
||||
# domain here — base64/hex/PEM keys — never contains one, so no extra guard.)
|
||||
emit_var() {
|
||||
local name="$1" value="$2" esc
|
||||
esc="${value//\\/\\\\}"
|
||||
esc="${esc//\"/\\\"}"
|
||||
printf '%s="%s"\n' "$name" "$esc"
|
||||
}
|
||||
|
||||
tmp="$(mktemp "${ENV_DIR}/.env.XXXXXX")"
|
||||
chmod 600 "$tmp"
|
||||
{
|
||||
printf '# Generated by fetch-config.sh for env=%s at %s — DO NOT EDIT.\n' \
|
||||
"$ENV" "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf '# Source: SSM /open-swe-%s/* + Secrets Manager open-swe-%s/*\n\n' "$ENV" "$ENV"
|
||||
for k in $(printf '%s\n' "${!VARS[@]}" | sort); do
|
||||
emit_var "$k" "${VARS[$k]}"
|
||||
done
|
||||
} >"$tmp"
|
||||
|
||||
mv -f "$tmp" "$ENV_FILE"
|
||||
# Owned by the unprivileged service user (T5 SC-01) so the app reads it without
|
||||
# running as root. fetch-config itself runs as root (ExecStartPre=+) to chown.
|
||||
chown "${SERVICE_USER}:${SERVICE_GROUP}" "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
# Point the app's CWD .env at the tmpfs file (idempotent).
|
||||
if [ "$APP_ENV_LINK" != "$ENV_FILE" ]; then
|
||||
if [ -L "$APP_ENV_LINK" ] || [ ! -e "$APP_ENV_LINK" ]; then
|
||||
ln -sfn "$ENV_FILE" "$APP_ENV_LINK"
|
||||
elif [ "$(readlink -f "$APP_ENV_LINK" 2>/dev/null || true)" != "$(readlink -f "$ENV_FILE")" ]; then
|
||||
log "WARNING: ${APP_ENV_LINK} exists and is not a symlink to ${ENV_FILE} — leaving it untouched"
|
||||
fi
|
||||
fi
|
||||
|
||||
total=$((ssm_count + secret_count))
|
||||
log "wrote ${ENV_FILE} (${total} vars, sandbox=${sandbox_type}) — ${SERVICE_USER}:${SERVICE_GROUP} 0600"
|
||||
32
deploy/seahaven/nginx/openswe.conf
Normal file
32
deploy/seahaven/nginx/openswe.conf
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Open SWE dashboard frontend (TanStack Start SPA) + scoped API proxy.
|
||||
# nginx is the security boundary: ONLY /dashboard/api/* reaches the backend;
|
||||
# the unauthenticated LangGraph agent API (/threads,/runs,/assistants,/store) is NOT proxied.
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name openswe.seahavenind.com;
|
||||
|
||||
root /var/www/openswe;
|
||||
index _shell.html;
|
||||
|
||||
# ALB health check
|
||||
location = /healthz { default_type text/plain; return 200 "ok\n"; }
|
||||
|
||||
# Dashboard API + OAuth callback -> backend webapp on :2024 (the ONLY proxied path)
|
||||
location /dashboard/api/ {
|
||||
proxy_pass http://127.0.0.1:2024;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
# Static assets + SPA shell fallback (client-side routing)
|
||||
location / {
|
||||
try_files $uri $uri/ /_shell.html;
|
||||
}
|
||||
}
|
||||
155
deploy/seahaven/put-config.sh
Executable file
155
deploy/seahaven/put-config.sh
Executable file
|
|
@ -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-<env>/<VAR> (value-LESS shells)
|
||||
# - the IaC-managed SSM params /open-swe-<env>/<VAR> (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-<env>` 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 <FILL> placeholder.
|
||||
# Replace <FILL...> inline at run time, pipe from a vault, or export the
|
||||
# matching OPENSWE_PUT_<VAR> 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-<env>/* and /open-swe-<env>/* (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 <dev|prod>" >&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-<env>/VAR.
|
||||
# Resolution order for the value: $OPENSWE_PUT_<VAR> env var, else the literal
|
||||
# <FILL> 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:-<FILL>}"
|
||||
if [ "$value" = "<FILL>" ]; 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-<env>/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:-<FILL>}"
|
||||
if [ "$value" = "<FILL>" ]; 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-<env>/<VAR>) — 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-<env>/<VAR>) ------------------------
|
||||
# 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."
|
||||
184
deploy/seahaven/seed_store.sh
Executable file
184
deploy/seahaven/seed_store.sh
Executable file
|
|
@ -0,0 +1,184 @@
|
|||
#!/usr/bin/env bash
|
||||
# Seed the LangGraph store after a (re)start.
|
||||
#
|
||||
# The stock `langgraph dev` server uses an IN-MEMORY store, so anything written
|
||||
# to it (team model settings, user mappings) is lost on every restart. This
|
||||
# script idempotently re-PUTs that state and is wired as a systemd
|
||||
# ExecStartPost on the open-swe.service unit so it runs after each start.
|
||||
# IT MUST RE-RUN ON EVERY RESTART — the in-memory store starts empty each boot.
|
||||
#
|
||||
# Replace this with Postgres-backed durability (Aegra / `langgraph up`) to make
|
||||
# the store survive restarts and drop this script.
|
||||
#
|
||||
# AWS-env-aware: pass the env as $1 (dev|prod). Seed values (default repo, model
|
||||
# ids, user mappings) come from the fetch-config-materialized .env.
|
||||
#
|
||||
# SECURITY (T5 /sh-security-review):
|
||||
# - SH-INJ-001: this script NEVER `source`s the .env. python-dotenv and bash
|
||||
# have incompatible escaping, and a config value like `$(cmd)` would execute
|
||||
# when sourced. We extract the few single-line seed keys with a non-eval
|
||||
# reader (read_env) instead.
|
||||
# - SH-INJ-003: the store PUT bodies are built with `jq --arg`, so values are
|
||||
# always JSON-encoded (no string interpolation into a JSON heredoc).
|
||||
# - SH-INJ-004: BASE is pinned to loopback — never derived from store/SSM
|
||||
# config (LANGGRAPH_URL) — so a tampered value can't redirect the PUTs.
|
||||
# - SC-02: not sourcing the .env means secrets are never exported into this
|
||||
# script's (or curl's) environment.
|
||||
#
|
||||
# Seed values read from the materialized .env (set in SSM /open-swe-<env>/*):
|
||||
# DEFAULT_REPO_OWNER / DEFAULT_REPO_NAME -> team_settings default_repo
|
||||
# LLM_MODEL_ID -> default builder model (fallback)
|
||||
# SEED_AGENT_MODEL / SEED_AGENT_EFFORT -> builder model + effort (optional)
|
||||
# SEED_REVIEWER_MODEL / SEED_REVIEWER_EFFORT -> reviewer model + effort (optional)
|
||||
# SEED_USER_MAPPINGS -> "login:email,login:email" (optional)
|
||||
# CONFIGURED_ADMINS -> "login,email" fallback for the mapping
|
||||
# Legacy OPENSWE_* process-env overrides are still honored (highest precedence).
|
||||
set -euo pipefail
|
||||
|
||||
ENV="${1:-${OPENSWE_ENV:-}}"
|
||||
case "$ENV" in
|
||||
dev | prod | "") ;; # empty allowed: pure-env / on-prem backward-compat mode
|
||||
*)
|
||||
echo "seed_store: ENV must be 'dev' or 'prod' (got '$ENV')" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
ENV_DIR="${ENV_DIR:-/run/open-swe}"
|
||||
ENV_FILE="${ENV_FILE:-${ENV_DIR}/.env}"
|
||||
|
||||
# read_env KEY -> prints the value of a SINGLE-LINE `KEY="..."` entry from the
|
||||
# materialized .env WITHOUT shell evaluation (SH-INJ-001 fix). Seed keys are
|
||||
# simple single-line values; multiline secrets (e.g. the PEM) are never read
|
||||
# here. Returns empty if the key is absent/unreadable.
|
||||
read_env() {
|
||||
local key="$1" line
|
||||
[ -r "$ENV_FILE" ] || return 0
|
||||
line="$(grep -m1 -- "^${key}=" "$ENV_FILE" 2>/dev/null || true)"
|
||||
[ -n "$line" ] || return 0
|
||||
line="${line#*=}"
|
||||
# strip one layer of surrounding double quotes (python-dotenv double-quoted form)
|
||||
if [ "${line#\"}" != "$line" ]; then line="${line%\"}"; line="${line#\"}"; fi
|
||||
# reverse python-dotenv double-quote escaping (only \" and \\ are escaped)
|
||||
line="${line//\\\"/\"}"; line="${line//\\\\/\\}"
|
||||
printf '%s' "$line"
|
||||
}
|
||||
|
||||
# Process-env override (legacy/on-prem) -> .env value -> default.
|
||||
pick() { # pick DEFAULT OVERRIDE_VALUE FILE_KEY...
|
||||
local def="$1" override="$2"; shift 2
|
||||
if [ -n "$override" ]; then printf '%s' "$override"; return; fi
|
||||
local k v
|
||||
for k in "$@"; do v="$(read_env "$k")"; [ -n "$v" ] && { printf '%s' "$v"; return; }; done
|
||||
printf '%s' "$def"
|
||||
}
|
||||
|
||||
# BASE is loopback-pinned (SH-INJ-004): this on-box seeder only talks to the
|
||||
# local server; OPENSWE_PORT may override the port but never the host.
|
||||
BASE="http://127.0.0.1:${OPENSWE_PORT:-2024}"
|
||||
|
||||
AGENT_MODEL="$(pick 'anthropic:claude-opus-4-8' "${OPENSWE_AGENT_MODEL:-}" SEED_AGENT_MODEL LLM_MODEL_ID)"
|
||||
AGENT_EFFORT="$(pick 'high' "${OPENSWE_AGENT_EFFORT:-}" SEED_AGENT_EFFORT)"
|
||||
REVIEWER_MODEL="$(pick 'openai:gpt-5.5' "${OPENSWE_REVIEWER_MODEL:-}" SEED_REVIEWER_MODEL)"
|
||||
REVIEWER_EFFORT="$(pick 'high' "${OPENSWE_REVIEWER_EFFORT:-}" SEED_REVIEWER_EFFORT)"
|
||||
|
||||
# default_repo = owner/name from AWS config (DEFAULT_REPO_OWNER is hard-pinned
|
||||
# away from upstream by fetch-config.sh).
|
||||
REPO_OWNER="$(pick '' '' DEFAULT_REPO_OWNER)"
|
||||
REPO_NAME="$(pick '' '' DEFAULT_REPO_NAME)"
|
||||
if [ -n "${OPENSWE_DEFAULT_REPO:-}" ]; then
|
||||
DEFAULT_REPO="$OPENSWE_DEFAULT_REPO"
|
||||
elif [ -n "$REPO_OWNER" ] && [ -n "$REPO_NAME" ]; then
|
||||
DEFAULT_REPO="${REPO_OWNER}/${REPO_NAME}"
|
||||
else
|
||||
echo "seed_store: set OPENSWE_DEFAULT_REPO=owner/repo (or DEFAULT_REPO_OWNER + DEFAULT_REPO_NAME in .env)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# user_mappings: explicit SEED_USER_MAPPINGS ("login:email,..."), then legacy
|
||||
# OPENSWE_OWNER_LOGIN/EMAIL, then parse CONFIGURED_ADMINS ("login,email").
|
||||
SEED_MAP="$(pick '' "${SEED_USER_MAPPINGS:-}" SEED_USER_MAPPINGS)"
|
||||
ADMINS="$(pick '' "${CONFIGURED_ADMINS:-}" CONFIGURED_ADMINS)"
|
||||
declare -a MAPPINGS=()
|
||||
if [ -n "$SEED_MAP" ]; then
|
||||
IFS=',' read -r -a _pairs <<<"$SEED_MAP"
|
||||
for p in "${_pairs[@]}"; do
|
||||
p="${p//[[:space:]]/}"
|
||||
[ -n "$p" ] && MAPPINGS+=("$p")
|
||||
done
|
||||
elif [ -n "${OPENSWE_OWNER_LOGIN:-}" ] && [ -n "${OPENSWE_OWNER_EMAIL:-}" ]; then
|
||||
MAPPINGS+=("${OPENSWE_OWNER_LOGIN}:${OPENSWE_OWNER_EMAIL}")
|
||||
elif [ -n "$ADMINS" ]; then
|
||||
_login="" _email=""
|
||||
IFS=',' read -r -a _toks <<<"$ADMINS"
|
||||
for t in "${_toks[@]}"; do
|
||||
t="${t//[[:space:]]/}"
|
||||
[ -z "$t" ] && continue
|
||||
case "$t" in
|
||||
*@*) [ -z "$_email" ] && _email="$t" ;;
|
||||
*) [ -z "$_login" ] && _login="$t" ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$_login" ] && [ -n "$_email" ] && MAPPINGS+=("${_login}:${_email}")
|
||||
fi
|
||||
|
||||
# No user mapping is NON-FATAL (OSWE-SEED-03 precedent: never fail the unit into a
|
||||
# restart loop over a seeding gap — same as the server-not-ready path below). The
|
||||
# server itself is healthy; an unseeded user_mappings table only means the @openswe
|
||||
# trigger won't resolve a commenter, which a deployment-validation env (e.g. dev)
|
||||
# does not need. team_settings is still seeded. Set SEED_USER_MAPPINGS (or
|
||||
# CONFIGURED_ADMINS / OPENSWE_OWNER_LOGIN+EMAIL) to seed the mapping when wanted.
|
||||
if [ "${#MAPPINGS[@]}" -eq 0 ]; then
|
||||
echo "seed_store: no user mapping resolved — skipping user_mappings seed (set SEED_USER_MAPPINGS or OPENSWE_OWNER_LOGIN/EMAIL to enable)" >&2
|
||||
fi
|
||||
|
||||
NOW="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)"
|
||||
|
||||
# Wait for the server to accept requests (up to ~60s). Authoritative (OSWE-SEED-03):
|
||||
# if it never comes up, log and exit 0 — do NOT fail the unit into a restart loop.
|
||||
READY=0
|
||||
for _ in $(seq 1 30); do
|
||||
if [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/ok" || true)" = "200" ]; then READY=1; break; fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$READY" -ne 1 ]; then
|
||||
echo "seed_store: server not ready at $BASE after ~60s; skipping seed (will reseed on next restart)" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 1) team_settings/default — JSON built with jq --arg (SH-INJ-003 fix).
|
||||
team_body="$(jq -n \
|
||||
--arg am "$AGENT_MODEL" --arg ae "$AGENT_EFFORT" \
|
||||
--arg rm "$REVIEWER_MODEL" --arg re "$REVIEWER_EFFORT" \
|
||||
--arg repo "$DEFAULT_REPO" --arg now "$NOW" \
|
||||
'{namespace:["team_settings"],key:"default",value:{
|
||||
review_draft_prs:false, pr_summaries:true, review_trace_links:true,
|
||||
org_guidelines:null,
|
||||
default_agent_model:$am, default_agent_reasoning_effort:$ae,
|
||||
default_agent_subagent_model:$am, default_agent_subagent_reasoning_effort:$ae,
|
||||
default_repo:$repo,
|
||||
default_reviewer_model:$rm, default_reviewer_reasoning_effort:$re,
|
||||
default_reviewer_subagent_model:$rm, default_reviewer_subagent_reasoning_effort:$re,
|
||||
default_grouping_model:null, default_grouping_reasoning_effort:null,
|
||||
default_chat_model:null, default_chat_reasoning_effort:null,
|
||||
updated_at:$now}}')"
|
||||
curl -fsS -X PUT "$BASE/store/items" -H "Content-Type: application/json" -d "$team_body" >/dev/null \
|
||||
|| echo "seed_store: WARN team_settings PUT failed (will reseed next restart)" >&2
|
||||
|
||||
# 2) user_mappings/<login> — required, or the @openswe trigger ignores the commenter.
|
||||
for pair in "${MAPPINGS[@]}"; do
|
||||
login="${pair%%:*}"
|
||||
email="${pair#*:}"
|
||||
if [ -z "$login" ] || [ -z "$email" ] || [ "$login" = "$pair" ]; then
|
||||
echo "seed_store: skipping malformed mapping '$pair' (want login:email)" >&2
|
||||
continue
|
||||
fi
|
||||
map_body="$(jq -n --arg login "$login" --arg email "$email" --arg now "$NOW" \
|
||||
'{namespace:["user_mappings"],key:$login,value:{
|
||||
github_login:$login, work_email:$email, slack_user_id:null,
|
||||
source:"slack_oauth", status:"active", created_at:$now, updated_at:$now}}')"
|
||||
curl -fsS -X PUT "$BASE/store/items" -H "Content-Type: application/json" -d "$map_body" >/dev/null \
|
||||
|| echo "seed_store: WARN user_mapping PUT failed for $login" >&2
|
||||
done
|
||||
|
||||
echo "seed_store: done at $NOW (env=${ENV:-none}, repo=$DEFAULT_REPO, mappings=${#MAPPINGS[@]})"
|
||||
17
deploy/seahaven/systemd/open-swe.service
Normal file
17
deploy/seahaven/systemd/open-swe.service
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[Unit]
|
||||
Description=Open SWE stock LangGraph dev server (graphs + webapp, :2024)
|
||||
After=network-online.target postgresql.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=adam
|
||||
WorkingDirectory=/home/adam/open-swe
|
||||
ExecStart=/home/adam/open-swe/.venv/bin/langgraph dev --host 0.0.0.0 --port 2024 --no-browser --no-reload
|
||||
ExecStartPost=/home/adam/open-swe/seed_store.sh
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=120
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -57,8 +57,8 @@ uv run python -m evals.reviewer.run_eval --limit 3
|
|||
|
||||
### From the GitHub Action (recommended for full runs)
|
||||
|
||||
Trigger the **Reviewer eval** workflow (`.github/workflows/reviewer_eval.yml`)
|
||||
from the Actions UI or `gh workflow run reviewer_eval.yml --ref prod -f limit=3`.
|
||||
Trigger the **Reviewer eval** workflow (`.github/workflows/reviewer-eval.yml`)
|
||||
from the Actions UI or `gh workflow run reviewer-eval.yml --ref prod -f limit=3`.
|
||||
Run it on the **prod** branch so the harness/judge match the deployed reviewer it
|
||||
scores. Running it on a durable runner (instead of inside the serving deployment)
|
||||
means a deploy or container recycle can't kill a long run.
|
||||
|
|
|
|||
21
infra/.gitignore
vendored
Normal file
21
infra/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# CDK / build output
|
||||
cdk.out/
|
||||
*.js
|
||||
*.d.ts
|
||||
*.js.map
|
||||
# ...but jest.config.js is hand-authored config, not build output — keep it.
|
||||
!jest.config.js
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
|
||||
# env
|
||||
.env
|
||||
|
||||
# coverage
|
||||
coverage/
|
||||
|
||||
# NOTE: cdk.context.json IS committed on purpose (pins the AMI / lookups so
|
||||
# deploys are reproducible and don't implicitly pick up a newer AMI — see
|
||||
# lib/constructs/ami-cache.ts and feedback_inline_ebs_volumes).
|
||||
!cdk.context.json
|
||||
301
infra/README.md
Normal file
301
infra/README.md
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
# open-swe infra (CDK TypeScript)
|
||||
|
||||
AWS infrastructure for the Open SWE → AWS migration. **Synth-only at this stage —
|
||||
nothing here is deployed yet.** All IAM is applied only after the Phase-1 security
|
||||
gate (T4 GPT-4.1 IAM cross-review + T5 `/sh-security-review`) clears (T6).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
infra/
|
||||
├── bin/
|
||||
│ └── app.ts # CDK app entry — instantiates the 3 stacks, applies the naming Aspect
|
||||
├── lib/
|
||||
│ ├── config.ts # account/region/org constants, env type, OIDC trust subjects
|
||||
│ ├── open-swe-iam-stack.ts # account-level: shared OIDC deploy roles
|
||||
│ ├── open-swe-stack.ts # per-env stack (instance role + config store + AppService)
|
||||
│ ├── aspects/
|
||||
│ │ └── kebab-naming-aspect.ts # fails synth on any non-kebab-case explicit name
|
||||
│ └── constructs/
|
||||
│ ├── github-deploy-roles.ts # githubdeploy-open-swe-infra + githubdeploy-open-swe-app
|
||||
│ ├── instance-role.ts # open-swe-<env>-instance-role (least-privilege)
|
||||
│ ├── config-store.ts # Secrets Manager + SSM Parameter Store shells (T11)
|
||||
│ ├── app-service.ts # EC2 box + imported-ALB ingress + Route53 + logs (T12)
|
||||
│ └── ami-cache.ts # baked open-swe AMI pin (by id) + EBS/replacement docs
|
||||
├── test/
|
||||
│ └── kebab-naming-aspect.test.ts # jest: Aspect passes conforming names, flags bad ones
|
||||
├── cdk.json
|
||||
├── cdk.context.json # COMMITTED — {} (AMI is a static id pin; no lookups)
|
||||
├── package.json # aws-cdk-lib pinned EXACT (2.260.0)
|
||||
├── tsconfig.json
|
||||
├── jest.config.js
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
## Stacks
|
||||
|
||||
| Stack name (kebab) | Construct | Contents |
|
||||
|---|---|---|
|
||||
| `open-swe-iam` | `OpenSweIamStack` | Account-level shared GitHub OIDC deploy roles (singletons). |
|
||||
| `open-swe-dev` | `OpenSweStack` (`envName: dev`) | `open-swe-dev-instance-role`, config store (T11), and the EC2 box + ALB ingress (T12, `AppService`). |
|
||||
| `open-swe-prod` | `OpenSweStack` (`envName: prod`) | `open-swe-prod-instance-role`, config store, and the EC2 box + ALB ingress. |
|
||||
|
||||
Account `328440206208`, region `us-east-1`. Stack names are set explicitly so CDK
|
||||
never defaults to PascalCase; resource names follow `open-swe-<env>-*`.
|
||||
|
||||
> The two env stacks (`open-swe-dev` / `open-swe-prod`) are the required pair. The
|
||||
> shared OIDC deploy roles are account-wide singletons (one `RoleName` each), so
|
||||
> they live in their own dedicated `open-swe-iam` stack rather than being
|
||||
> duplicated across the env stacks — and that stack deploys first (see ordering).
|
||||
|
||||
## IAM roles defined (unapplied)
|
||||
|
||||
- **`githubdeploy-open-swe-infra`** — GitHub OIDC role for CDK/CFN infra deploys.
|
||||
Trust scoped to `repo:Sea-Haven-Industries/open-swe` on the `main`/`dev`
|
||||
branches only. Permission is the org-standard CDK pattern: `sts:AssumeRole` on
|
||||
the CDK bootstrap roles (`cdk-hnb659fds-*`) — the real CFN/IAM/resource scope
|
||||
lives in the bootstrap `cfn-exec-role`, not in this role.
|
||||
- **`githubdeploy-open-swe-app`** — GitHub OIDC role for app deploys. Tag-scoped
|
||||
`ssm:SendCommand` (instances tagged `project=open-swe` + `env in {dev,prod}`) +
|
||||
read-only access to the `open-swe-<env>-assets` S3 artifact buckets.
|
||||
- **`open-swe-<env>-instance-role`** — EC2 instance role, least-privilege: read
|
||||
`open-swe-<env>-assets` (S3), read `/open-swe-<env>/*` (SSM), read
|
||||
`open-swe-<env>/*` (Secrets Manager), put `/open-swe/<env>/*` CloudWatch Logs,
|
||||
plus `AmazonSSMManagedInstanceCore` for SSM agent registration. No admin.
|
||||
|
||||
The GitHub OIDC provider already exists account-wide (created for seahaven-site);
|
||||
it is referenced by ARN, never re-created.
|
||||
|
||||
## Kebab-case naming Aspect
|
||||
|
||||
`KebabNamingAspect` (applied app-wide in `bin/app.ts`) fails synth via
|
||||
`Annotations.addError` when a stack name or an explicit physical resource name
|
||||
(`RoleName`, `BucketName`, …) is not kebab-case. Path-style names (Secrets
|
||||
Manager `a/b`, SSM `/a/b`, log groups `/aws/.../x`) are validated per `/`-segment.
|
||||
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-<env>/<VAR>` for secrets,
|
||||
`/open-swe-<env>/<VAR>` (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-<env>` but **before** the EC2/T12 box first boots:
|
||||
|
||||
```bash
|
||||
cdk deploy open-swe-<env> # creates the 27 secret shells + 8 IaC params
|
||||
deploy/seahaven/put-config.sh <dev|prod> # sets the 27 secret values + out-of-band SSM
|
||||
deploy/seahaven/fetch-config.sh <dev|prod> # (on the box) fail-fast verify before first start
|
||||
```
|
||||
|
||||
`put-config.sh` ships `<FILL>` placeholders only (no real secret values committed);
|
||||
provide each value inline, via `OPENSWE_PUT_<VAR>` env vars, or from a vault. It does
|
||||
NOT touch the IaC-managed params (CDK owns those — editing them here would drift).
|
||||
|
||||
## Compute + ingress (`AppService` — T12)
|
||||
|
||||
`AppService` (`lib/constructs/app-service.ts`, one per env from `OpenSweStack`)
|
||||
builds the box and its path to the internet. A **single** internet-facing ALB
|
||||
(`app/seahaven-com`) and a **single** VPC are shared with the on-prem
|
||||
`seahaven-site` stack, so open-swe **imports** the VPC, the ALB security group
|
||||
(`sg-0b0301deed193258a`), the `:443` listener, and the public `seahaven.com`
|
||||
zone — and never owns/mutates them. It **adds**:
|
||||
|
||||
- **One ARM64 EC2 box** (`open-swe-<env>-box`, `t4g.medium` dev / `t4g.large`
|
||||
prod) in **private1 (us-east-1a)** — same AZ as the single NAT for in-AZ egress.
|
||||
`requireImdsv2`, gp3 **encrypted** root, `deleteOnTermination` (no RETAIN
|
||||
volume — see below). `userDataCausesReplacement: true`; user-data is rendered
|
||||
from `deploy/ami/user-data.sh`.
|
||||
- **A standalone instance SG** reachable **only** from the shared ALB SG on `:80`
|
||||
(nginx). Egress open (NAT). The ALB SG is opened to the box via a **standalone
|
||||
`CfnSecurityGroupEgress`** so the imported (on-prem-owned) SG is never mutated.
|
||||
- **A target group → instance `:80`** (nginx is the sole ingress; the LangGraph
|
||||
control plane stays on loopback `:2024`). Health check `GET /healthz`.
|
||||
- **Two listener rules** on the imported `:443` listener, both → the same TG:
|
||||
- **Webhooks** (priority **2** dev / **3** prod): `host ∈ {openswe-<env>, hooks-<env>}.seahaven.com` **AND** path `/webhooks/*`.
|
||||
- **Site** (priority **10** dev / **11** prod): `host = openswe-<env>.seahaven.com` (dashboard SPA + `/dashboard/api/`).
|
||||
- **Route53 alias records** `openswe[-dev]` + `hooks[-dev]` → the shared ALB.
|
||||
- **Four CloudWatch log groups** (`/open-swe/<env>/{app,user-data,nginx-access,nginx-error}`) at **30-day** retention (IaC-owned; mirrors the CW-agent config).
|
||||
|
||||
### Listener-rule ordering (load-bearing)
|
||||
|
||||
The shared listener already has a **host-agnostic** `/webhooks/*` PATH rule at
|
||||
**priority 5** (on-prem). ALB rules are first-match by ascending priority, so the
|
||||
open-swe webhook rule **must** sit below 5 or every `…/webhooks/*` request (any
|
||||
host) is forwarded to the on-prem target first. Hence priority 2/3. The rule ANDs
|
||||
a host condition, so it does **not** steal the on-prem hosts' webhooks. The
|
||||
dashboard "site" rule carries no path that collides with rule 5, so it sits at
|
||||
10/11.
|
||||
|
||||
**Cross-stack coordination (T13 review).** The `seahaven-site` (on-prem) and
|
||||
`open-swe` stacks both add resources to the *same imported* listener and ALB SG.
|
||||
This is safe: each stack owns only the resources it declares (its own logical
|
||||
ids), so an on-prem deploy can't delete open-swe's rules/egress and vice-versa,
|
||||
and the standalone `CfnSecurityGroupEgress` never mutates the shared SG's own
|
||||
definition (the pattern on-prem itself uses). The one shared namespace that needs
|
||||
care is **listener-rule priority** (globally unique per listener; a collision is
|
||||
a fail-*safe* deploy error, not silent drift). Ownership — keep disjoint:
|
||||
`seahaven-site` = **4-7 + default**; `open-swe` = **2, 3, 10, 11**. open-swe's
|
||||
webhook rules are host-scoped to its own `*.seahaven.com` hosts, so they never
|
||||
match an on-prem `seahavenind.com` host.
|
||||
|
||||
### Security review (T5/T12 `/sh-security-review`)
|
||||
|
||||
The T12 surface was run through the detector-fan-out + proof-or-kill verifier.
|
||||
One **confirmed medium** (OSWE-T12-01: nginx's 1 MB default `client_max_body_size`
|
||||
would 413 large GitHub webhooks before in-app signature verification) is fixed in
|
||||
`open-swe.nginx.conf` (`25m` on `/webhooks/`, `10m` on `/dashboard/api/`). The
|
||||
hooks hostname is scoped to `/webhooks/*` only (OSWE-T12-02 hygiene). An
|
||||
X-Forwarded-For spoof candidate was **killed** — no code trusts the leftmost XFF.
|
||||
No confirmed critical/high; no block.
|
||||
|
||||
## Baked AMI + EBS-replacement discipline
|
||||
|
||||
`bakedOpenSweArm64()` (in `lib/constructs/ami-cache.ts`) pins the custom
|
||||
**open-swe-base-arm64** image by EXACT id (`BAKED_OPEN_SWE_AMI_ID`) via
|
||||
`MachineImage.genericLinux({ "us-east-1": "<ami-id>" })` — no SSM lookup, so synth
|
||||
and deploy are fully offline/deterministic. The image is built by
|
||||
`deploy/ami/open-swe-base.pkr.hcl` (ARM64 Ubuntu 24.04 + uv/py3.12 + nginx + CW
|
||||
agent + boot templates, **no secrets**); the box's `user-data.sh` assumes that
|
||||
baked layout (`/opt/open-swe`, `openswe` user, nginx, CW agent).
|
||||
|
||||
Pinning by exact id (vs a `most_recent` name filter) is what prevents a routine
|
||||
deploy from silently swapping the AMI → **EC2 instance replacement** (the
|
||||
file-share data-loss root cause — memory `feedback_inline_ebs_volumes`).
|
||||
|
||||
- `userDataCausesReplacement: true` is **deliberate** — user-data is
|
||||
provisioning-only and carries no durable state.
|
||||
- **No durable state on the box → no RETAIN volume.** The in-memory langgraph
|
||||
store is rebuilt on every boot from S3 + Secrets Manager / SSM, so there is
|
||||
intentionally no standalone `ec2.Volume` + `removalPolicy.RETAIN`. The goal is
|
||||
replacement-*tolerance*, not avoidance.
|
||||
- **Snapshot-before-replace** still applies operationally: before any replacing
|
||||
deploy snapshot the root volume and wait `state=completed`, and re-verify "no
|
||||
local-only durable state" first.
|
||||
|
||||
Refresh the AMI deliberately:
|
||||
|
||||
```bash
|
||||
cd deploy/ami && packer build open-swe-base.pkr.hcl # prints the new ami-… id
|
||||
# update BAKED_OPEN_SWE_AMI_ID in infra/lib/constructs/ami-cache.ts
|
||||
cd infra && npx cdk diff OpenSweDevStack # WILL show "requires replacement"
|
||||
```
|
||||
|
||||
> `cdk.context.json` is `{}` — nothing is resolved via context anymore (the AMI is
|
||||
> a static id pin), so synth makes no live AWS call.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx cdk synth open-swe-iam
|
||||
npx cdk synth open-swe-dev
|
||||
npx cdk synth open-swe-prod
|
||||
npm test # jest — naming Aspect
|
||||
```
|
||||
|
||||
## CI/CD (T18 — `.github/workflows/ci-infra.yml` + `cd-infra.yml`)
|
||||
|
||||
Path-filtered, OIDC-only (no static keys). The Python agent keeps its own
|
||||
`ci.yml` ("CI"); these two add the `/infra` half.
|
||||
|
||||
| Workflow | Trigger | Does |
|
||||
|---|---|---|
|
||||
| `ci-infra.yml` | PR touching `infra/**` | `tsc` + `jest` + `cdk synth` (reusable `ci-typescript-cdk.yaml`). |
|
||||
| `cd-infra.yml` | push to `dev`/`main` touching `infra/**`, or dispatch | CI (pre-deploy) → per-env `cdk deploy`. |
|
||||
|
||||
`cd-infra.yml` flow:
|
||||
|
||||
- **push to `dev`** → CI green → **auto** `cdk deploy OpenSweDevStack` (assumes
|
||||
`githubdeploy-open-swe-infra-dev`; the job declares **no** `environment:`, so the
|
||||
OIDC subject is `…:ref:refs/heads/dev` — matching that role's trust).
|
||||
- **push to `main`** → CI green → `cdk deploy OpenSweProdStack` behind the
|
||||
**`prod` GitHub Environment** (required reviewer = Adam). The `environment: prod`
|
||||
declaration both fires the manual-approval gate and makes the OIDC subject
|
||||
`…:environment:prod` — matching `githubdeploy-open-swe-infra-prod`'s trust.
|
||||
|
||||
**Why not the reusable `cd-cdk.yaml`:** it runs `cdk deploy --all`, which from a
|
||||
single-env push would deploy the *other* env + the shared IAM stack — breaking the
|
||||
per-env boundary. So CD targets one stack explicitly per env. The shared
|
||||
`open-swe-iam` stack is **not** deployed by CD (privileged, human-gated — T6).
|
||||
|
||||
**Gating note:** infra CI is enforced at the *deploy* boundary (`cd-infra`'s
|
||||
`deploy-*` jobs `needs: ci`), not as a branch-protection required check —
|
||||
path-filtering a *required* check would deadlock app-only PRs (a skipped required
|
||||
check never satisfies). Making `Infra CI` a required check later needs a
|
||||
skip-aware shim or dropping its path filter.
|
||||
|
||||
**Prerequisites (set post-T6, when the roles exist):**
|
||||
|
||||
- repo **variables** `AWS_DEPLOY_ROLE_INFRA_DEV` / `AWS_DEPLOY_ROLE_INFRA_PROD`
|
||||
= the `githubdeploy-open-swe-infra-<env>` role ARNs (`open-swe-iam` outputs).
|
||||
- a GitHub **Environment** named `prod` with Adam as a required reviewer.
|
||||
|
||||
> App-side CD (CI → S3 artifact → SSM deploy via `githubdeploy-open-swe-app-<env>`)
|
||||
> is **T19**, not here.
|
||||
|
||||
## Deploy ordering (when the gate clears — NOT yet)
|
||||
|
||||
1. **`open-swe-iam` first** — apply the IAM stack (T6, human-gated), then set the
|
||||
repo `AWS_DEPLOY_ROLE_INFRA_{DEV,PROD}` variables from its role-ARN outputs and
|
||||
configure the `prod` Environment reviewer (BLOCK#3).
|
||||
2. **Security gate** — T4 GPT-4.1 IAM cross-review + T5 `/sh-security-review` on
|
||||
the synth; resolve every confirmed critical/high.
|
||||
3. **IAM applied** (T6) — only after the gate.
|
||||
4. Env stacks: first `open-swe-dev` (T14, manual validate), then CD auto-deploys
|
||||
dev on push; `open-swe-prod` (T21) behind the `prod` Environment approval.
|
||||
|
||||
## Version policy
|
||||
|
||||
`aws-cdk-lib` is pinned EXACT (`2.260.0`) — no `^`/`~`. Dependabot keeps it
|
||||
current; CI (`npm ci` + `cdk synth`) + dependency review gate each bump. See
|
||||
`aws-infrastructure.md` "CDK Version Policy" and memory
|
||||
`feedback_cdk_lib_bundled_deps`.
|
||||
35
infra/bin/app.ts
Normal file
35
infra/bin/app.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env node
|
||||
import "source-map-support/register";
|
||||
import * as cdk from "aws-cdk-lib";
|
||||
import { ACCOUNT, REGION } from "../lib/config";
|
||||
import { OpenSweIamStack } from "../lib/open-swe-iam-stack";
|
||||
import { OpenSweStack } from "../lib/open-swe-stack";
|
||||
import { KebabNamingAspect } from "../lib/aspects/kebab-naming-aspect";
|
||||
|
||||
const app = new cdk.App();
|
||||
const env = { account: ACCOUNT, region: REGION };
|
||||
|
||||
// Account-level shared OIDC deploy roles (singletons). Deployed FIRST.
|
||||
new OpenSweIamStack(app, "OpenSweIamStack", {
|
||||
stackName: "open-swe-iam",
|
||||
env,
|
||||
});
|
||||
|
||||
// The two env stacks — explicit kebab-case stackName (never let CDK default to
|
||||
// PascalCase), env-parameterised so resources are `open-swe-<env>-*`.
|
||||
new OpenSweStack(app, "OpenSweDevStack", {
|
||||
stackName: "open-swe-dev",
|
||||
env,
|
||||
envName: "dev",
|
||||
});
|
||||
|
||||
new OpenSweStack(app, "OpenSweProdStack", {
|
||||
stackName: "open-swe-prod",
|
||||
env,
|
||||
envName: "prod",
|
||||
});
|
||||
|
||||
// Fail synth on any non-kebab-case explicit resource/stack name.
|
||||
cdk.Aspects.of(app).add(new KebabNamingAspect());
|
||||
|
||||
app.synth();
|
||||
1
infra/cdk.context.json
Normal file
1
infra/cdk.context.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{}
|
||||
21
infra/cdk.json
Normal file
21
infra/cdk.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
|
||||
"watch": {
|
||||
"include": ["**"],
|
||||
"exclude": [
|
||||
"README.md",
|
||||
"cdk*.json",
|
||||
"**/*.d.ts",
|
||||
"**/*.js",
|
||||
"tsconfig.json",
|
||||
"package*.json",
|
||||
"node_modules",
|
||||
"cdk.out"
|
||||
]
|
||||
},
|
||||
"context": {
|
||||
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
|
||||
"@aws-cdk/core:checkSecretUsage": true,
|
||||
"@aws-cdk/core:target-partitions": ["aws"]
|
||||
}
|
||||
}
|
||||
9
infra/jest.config.js
Normal file
9
infra/jest.config.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/test"],
|
||||
testMatch: ["**/*.test.ts"],
|
||||
preset: "ts-jest",
|
||||
transform: {
|
||||
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json" }],
|
||||
},
|
||||
};
|
||||
119
infra/lib/aspects/kebab-naming-aspect.ts
Normal file
119
infra/lib/aspects/kebab-naming-aspect.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { Annotations, CfnResource, IAspect, Stack, Token } from "aws-cdk-lib";
|
||||
import { IConstruct } from "constructs";
|
||||
|
||||
/**
|
||||
* One "/"-delimited segment must be lower kebab-case: `a-b-c`, digits allowed.
|
||||
*/
|
||||
const KEBAB_SEGMENT = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
||||
|
||||
/**
|
||||
* CloudFormation property keys that carry an *explicit physical name*. The
|
||||
* codegen'd L1 stores these either camelCased (`roleName`) or CFN-cased
|
||||
* (`RoleName`) depending on the construct, so the aspect matches keys
|
||||
* case-insensitively.
|
||||
*
|
||||
* We deliberately validate physical NAMES + the stack name only — not CDK
|
||||
* logical construct ids (those are conventionally PascalCase, e.g.
|
||||
* `InfraDeployRole`, and validating them would be wrong).
|
||||
*/
|
||||
const NAME_PROPERTY_KEYS = [
|
||||
"RoleName",
|
||||
"BucketName",
|
||||
"FunctionName",
|
||||
"TableName",
|
||||
"LogGroupName",
|
||||
"QueueName",
|
||||
"TopicName",
|
||||
"SecretName",
|
||||
"StreamName",
|
||||
"RepositoryName",
|
||||
"DBInstanceIdentifier",
|
||||
"DBClusterIdentifier",
|
||||
"StateMachineName",
|
||||
"RuleName",
|
||||
"UserPoolName",
|
||||
];
|
||||
// NOTE: `PolicyName` is intentionally NOT checked — CDK auto-generates inline
|
||||
// `DefaultPolicy` names (e.g. "InstanceRoleDefaultPolicyF15F...") from the
|
||||
// logical id; those are not explicit, user-controlled physical names and are
|
||||
// outside the naming convention's scope.
|
||||
|
||||
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-<env>/<ENV_VAR_NAME>` and SSM
|
||||
* params `/open-swe-<env>/<ENV_VAR_NAME>`, 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-<env>`
|
||||
* 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.
|
||||
*
|
||||
* Path-style names are tolerated so the same check works for Secrets Manager
|
||||
* (`open-swe-dev/foo`), SSM params (`/open-swe-dev/foo`) and log groups
|
||||
* (`/open-swe/dev/agent`): each segment is validated independently, and a
|
||||
* leading slash (empty first segment) is ignored.
|
||||
*/
|
||||
export function isKebabCase(value: string): boolean {
|
||||
return value
|
||||
.split("/")
|
||||
.filter((seg) => seg.length > 0)
|
||||
.every((seg) => KEBAB_SEGMENT.test(seg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Aspect that FAILS synth (`Annotations.addError`) when an explicitly-named
|
||||
* resource — or a stack name — is not kebab-case. Enforces the org naming
|
||||
* convention (naming-conventions.md) deterministically at synth time so a
|
||||
* non-conforming name can never reach a deploy. Wired in bin/app.ts via
|
||||
* `Aspects.of(app).add(new KebabNamingAspect())`.
|
||||
*/
|
||||
export class KebabNamingAspect implements IAspect {
|
||||
public visit(node: IConstruct): void {
|
||||
if (node instanceof Stack) {
|
||||
const name = node.stackName;
|
||||
if (!Token.isUnresolved(name) && !isKebabCase(name)) {
|
||||
Annotations.of(node).addError(
|
||||
`Stack name "${name}" is not kebab-case (open-swe naming convention).`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
const raw = (node as any)._cfnProperties ?? {};
|
||||
const resolved = Stack.of(node).resolve(raw) ?? {};
|
||||
for (const [key, value] of Object.entries(resolved)) {
|
||||
if (
|
||||
NAME_KEYS_LC.has(key.toLowerCase()) &&
|
||||
typeof value === "string" &&
|
||||
!Token.isUnresolved(value) &&
|
||||
!isKebabCase(value)
|
||||
) {
|
||||
Annotations.of(node).addError(
|
||||
`Resource "${node.node.path}" property ${key}="${value}" is not kebab-case ` +
|
||||
`(open-swe naming convention).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
47
infra/lib/config.ts
Normal file
47
infra/lib/config.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Shared, non-sensitive constants for the open-swe infra app.
|
||||
* Account / region are locked per the migration spec (TODO.md "Architecture (locked)").
|
||||
*/
|
||||
|
||||
export const ACCOUNT = "328440206208";
|
||||
export const REGION = "us-east-1";
|
||||
|
||||
export const GITHUB_ORG = "Sea-Haven-Industries";
|
||||
export const GITHUB_REPO = "open-swe";
|
||||
|
||||
export type EnvName = "dev" | "prod";
|
||||
|
||||
/** `open-swe-dev` / `open-swe-prod` — kebab-case stack + resource prefix. */
|
||||
export const prefix = (env: EnvName): string => `open-swe-${env}`;
|
||||
|
||||
/**
|
||||
* Per-ENV GitHub OIDC trust subject for the deploy roles (T5 OSWE-IAC-01/02 fix:
|
||||
* the dev/prod boundary is enforced in the IAM trust, not by convention).
|
||||
*
|
||||
* - `dev` → the `dev` integration branch ref (auto-deploy on push to dev).
|
||||
* - `prod` → the **GitHub `prod` Environment** subject. A workflow can only mint
|
||||
* a token with sub `…:environment:prod` by declaring `environment: prod`,
|
||||
* which triggers the Environment's manual-approval gate (Adam, T18). So the
|
||||
* prod approval is now expressed at the IAM layer: a dev-branch token can
|
||||
* never assume a prod deploy role.
|
||||
*
|
||||
* Each env gets its OWN infra + app role (githubdeploy-open-swe-{infra,app}-<env>)
|
||||
* so a dev token cannot reach prod. Exact subject → StringEquals (no `*`).
|
||||
*
|
||||
* Residual (documented): CDK's single account-wide `cfn-exec-role` means the dev
|
||||
* INFRA role can still technically `cdk deploy open-swe-prod`; the workflow only
|
||||
* ever targets its own env stack, and prod's environment-gated role is the
|
||||
* approved path. Per-env bootstrap qualifiers would close this fully (future).
|
||||
*/
|
||||
export const oidcSubject = (env: EnvName): string =>
|
||||
env === "prod"
|
||||
? `repo:${GITHUB_ORG}/${GITHUB_REPO}:environment:prod`
|
||||
: `repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/dev`;
|
||||
|
||||
/**
|
||||
* The GitHub Actions OIDC provider already exists account-wide (created for
|
||||
* seahaven-site; see .github/oidc-deploy-roles.yaml `CreateOIDCProvider=false`).
|
||||
* Reference it by ARN — never create a duplicate `AWS::IAM::OIDCProvider`
|
||||
* (CloudFormation rejects a second provider for the same URL).
|
||||
*/
|
||||
export const GITHUB_OIDC_PROVIDER_ARN = `arn:aws:iam::${ACCOUNT}:oidc-provider/token.actions.githubusercontent.com`;
|
||||
35
infra/lib/constructs/ami-cache.ts
Normal file
35
infra/lib/constructs/ami-cache.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||
import { REGION } from "../config";
|
||||
|
||||
/**
|
||||
* The baked open-swe base AMI (ARM64 Ubuntu 24.04 + uv/py3.12 + nginx + CW agent
|
||||
* + boot templates — NO secrets), produced by `deploy/ami/open-swe-base.pkr.hcl`.
|
||||
* Pinned by EXACT id (not a name filter) so synth/deploy is fully offline and
|
||||
* deterministic.
|
||||
*
|
||||
* Built 2026-06-26 from open-swe-base-arm64-20260626-203433.
|
||||
*
|
||||
* ── EBS / AMI replacement discipline (memory feedback_inline_ebs_volumes) ──
|
||||
*
|
||||
* Refresh DELIBERATELY: `cd deploy/ami && packer build open-swe-base.pkr.hcl`,
|
||||
* then update this id. A new id → EC2 instance REPLACEMENT. Pinning by exact id
|
||||
* (vs a `most_recent` name filter) is what prevents a routine deploy from silently
|
||||
* swapping the AMI — the root cause of the file-share data-loss incidents
|
||||
* (5/15, 5/27, 6/5).
|
||||
*
|
||||
* `userDataCausesReplacement: true` (AppService) is likewise DELIBERATE: user-data
|
||||
* is provisioning-only and the box holds NO durable state (the langgraph store is
|
||||
* in-memory, rebuilt every boot from S3 + Secrets Manager / SSM), so there is
|
||||
* intentionally no standalone `ec2.Volume` + `removalPolicy.RETAIN`. The design
|
||||
* goal is replacement-TOLERANCE, not avoidance.
|
||||
*
|
||||
* Operational guard before ANY replacing deploy (AMI / userData / instance-type):
|
||||
* snapshot the root volume AND wait `state=completed`, re-verify "no local-only
|
||||
* durable state", and review the `cdk diff` replacement at PR time.
|
||||
*/
|
||||
export const BAKED_OPEN_SWE_AMI_ID = "ami-00080084502093021";
|
||||
|
||||
/** The baked open-swe base image, pinned by id (offline, deterministic). */
|
||||
export function bakedOpenSweArm64(): ec2.IMachineImage {
|
||||
return ec2.MachineImage.genericLinux({ [REGION]: BAKED_OPEN_SWE_AMI_ID });
|
||||
}
|
||||
347
infra/lib/constructs/app-service.ts
Normal file
347
infra/lib/constructs/app-service.ts
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as cdk from "aws-cdk-lib";
|
||||
import * as ec2 from "aws-cdk-lib/aws-ec2";
|
||||
import * as elbv2 from "aws-cdk-lib/aws-elasticloadbalancingv2";
|
||||
import * as elbTargets from "aws-cdk-lib/aws-elasticloadbalancingv2-targets";
|
||||
import * as logs from "aws-cdk-lib/aws-logs";
|
||||
import * as route53 from "aws-cdk-lib/aws-route53";
|
||||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import * as ssm from "aws-cdk-lib/aws-ssm";
|
||||
import { Construct } from "constructs";
|
||||
import { EnvName, prefix } from "../config";
|
||||
import { bakedOpenSweArm64 } from "./ami-cache";
|
||||
|
||||
/**
|
||||
* Shared seahaven-vpc + internet-facing ALB facts (read-only recon 2026-06-26;
|
||||
* scratchpad/T12-infra-facts.md). A SINGLE VPC and a SINGLE shared ALB front
|
||||
* both the on-prem `seahaven-site` stack and open-swe. We IMPORT every one of
|
||||
* these and NEVER own them - open-swe only ADDS its own instance SG, a standalone
|
||||
* ALB-egress rule, listener rules, a target group, and DNS records.
|
||||
*
|
||||
* ── Cross-stack coordination on the SHARED listener + ALB SG (T13 review) ──
|
||||
* Two CDK stacks (seahaven-site, open-swe) add resources to the same imported
|
||||
* `:443` listener and ALB SG. This is safe because each stack owns ONLY the
|
||||
* resources it declares (its own logical ids): an on-prem `cdk deploy` computes a
|
||||
* changeset over its own template and cannot delete rules/egress it never
|
||||
* declared. The standalone-egress pattern is what on-prem itself uses
|
||||
* (sgr-0c57812752a3bca13), so it does not mutate the shared SG's own definition.
|
||||
*
|
||||
* The ONE shared namespace that REQUIRES coordination is listener-rule PRIORITY
|
||||
* (globally unique per listener; a collision is a fail-SAFE deploy error, not
|
||||
* silent drift). Ownership map - keep these disjoint when editing either stack:
|
||||
* - seahaven-site (on-prem): priorities 4-7 + default.
|
||||
* - open-swe: priorities 2, 3 (webhooks) and 10, 11 (site).
|
||||
* open-swe's webhook rules are HOST-scoped to its own *.seahaven.com hosts, so
|
||||
* they never match (let alone "steal") any seahavenind.com / on-prem host.
|
||||
*/
|
||||
const SHARED = {
|
||||
vpcId: "vpc-0d3d4b67bd0cf8a68",
|
||||
availabilityZones: ["us-east-1a", "us-east-1b"],
|
||||
// Private subnets host the EC2 box. The single NAT gateway lives in 1a, so the
|
||||
// box is pinned to private1 (1a) for in-AZ NAT egress (no cross-AZ data $).
|
||||
privateSubnetIds: ["subnet-04e38c507e96f1926", "subnet-0a0b4fc6f296dfba5"],
|
||||
instanceSubnetId: "subnet-04e38c507e96f1926",
|
||||
instanceAz: "us-east-1a",
|
||||
albDnsName: "seahaven-com-1856441924.us-east-1.elb.amazonaws.com",
|
||||
albCanonicalHostedZoneId: "Z35SXDOTRQ7X7K",
|
||||
albSecurityGroupId: "sg-0b0301deed193258a",
|
||||
httpsListenerArn:
|
||||
"arn:aws:elasticloadbalancing:us-east-1:328440206208:listener/app/seahaven-com/222c3257354ab559/bab8bcf0da0e2927",
|
||||
publicZoneId: "Z06652411XKH89KTZD3XA",
|
||||
publicZoneName: "seahaven.com",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Per-env public hostnames, listener-rule priorities, and instance size.
|
||||
*
|
||||
* ── Listener-rule ordering hazard (load-bearing) ──
|
||||
* The shared listener already has a HOST-AGNOSTIC `/webhooks/*` PATH rule at
|
||||
* priority 5 (the on-prem seahaven-site stack owns it). ALB rules are first-match
|
||||
* by ASCENDING priority, so a `…/webhooks/*` request to our host would match
|
||||
* rule 5 (priority 5) and be forwarded to the on-prem target BEFORE any host rule
|
||||
* at 10+. Therefore our webhook rule MUST sit below priority 5. The catch-all
|
||||
* "site" rule (dashboard SPA + /dashboard/api/) carries no path that collides
|
||||
* with rule 5, so it can sit at any free higher number (10/11). Free priorities
|
||||
* confirmed by recon: 1-3 and 8+ (4=forgejo, 5=/webhooks/*, 6/7=seahavenind).
|
||||
*/
|
||||
const ENV_NET: Record<
|
||||
EnvName,
|
||||
{
|
||||
dashboardHost: string;
|
||||
hooksHost: string;
|
||||
webhookPriority: number;
|
||||
sitePriority: number;
|
||||
instanceType: string;
|
||||
}
|
||||
> = {
|
||||
dev: {
|
||||
dashboardHost: "openswe-dev.seahaven.com",
|
||||
hooksHost: "hooks-dev.seahaven.com",
|
||||
webhookPriority: 2,
|
||||
sitePriority: 10,
|
||||
instanceType: "t4g.medium",
|
||||
},
|
||||
prod: {
|
||||
dashboardHost: "openswe.seahaven.com",
|
||||
hooksHost: "hooks.seahaven.com",
|
||||
webhookPriority: 3,
|
||||
sitePriority: 11,
|
||||
instanceType: "t4g.large",
|
||||
},
|
||||
};
|
||||
|
||||
export interface AppServiceProps {
|
||||
readonly envName: EnvName;
|
||||
/** Least-privilege EC2 instance role (per-env; from InstanceRole). */
|
||||
readonly instanceRole: iam.IRole;
|
||||
/** S3 artifact key prefix the box pulls app.tar.gz / spa.tar.gz from. */
|
||||
readonly artifactPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The open-swe compute + ingress wiring for one env (T12):
|
||||
* - one ARM64 EC2 box in private1 (1a), replacement-tolerant (no RETAIN volume),
|
||||
* - a standalone instance SG reachable ONLY from the shared ALB SG on :80,
|
||||
* - a target group -> instance:80 (nginx is the sole ingress; :2024 stays loopback),
|
||||
* - two listener rules on the imported :443 listener (webhooks below the on-prem
|
||||
* path rule; site catch-all above it), both -> the same TG,
|
||||
* - Route53 alias records for both hostnames -> the shared ALB,
|
||||
* - IaC-owned CloudWatch log groups at 30-day retention.
|
||||
*
|
||||
* Everything ALB/VPC/zone-side is IMPORTED. Synth is offline: the AMI is the
|
||||
* cdk.context.json-pinned AL2023 ARM64 placeholder until the baked
|
||||
* open-swe-base-arm64 id is pinned before the first real deploy.
|
||||
*/
|
||||
export class AppService extends Construct {
|
||||
public readonly instance: ec2.Instance;
|
||||
public readonly targetGroup: elbv2.ApplicationTargetGroup;
|
||||
/** Name of the SSM document CI fires to roll the box to the latest release. */
|
||||
public readonly deployDocumentName: string;
|
||||
|
||||
constructor(scope: Construct, id: string, props: AppServiceProps) {
|
||||
super(scope, id);
|
||||
const env = props.envName;
|
||||
const p = prefix(env);
|
||||
const net = ENV_NET[env];
|
||||
const artifactPrefix = props.artifactPrefix ?? "releases/latest";
|
||||
|
||||
// Import the shared VPC with explicit attributes (no fromLookup -> offline synth).
|
||||
const vpc = ec2.Vpc.fromVpcAttributes(this, "Vpc", {
|
||||
vpcId: SHARED.vpcId,
|
||||
availabilityZones: [...SHARED.availabilityZones],
|
||||
privateSubnetIds: [...SHARED.privateSubnetIds],
|
||||
});
|
||||
|
||||
// Standalone instance SG. Egress open (NAT path); ingress only from the ALB SG.
|
||||
const instanceSg = new ec2.SecurityGroup(this, "InstanceSg", {
|
||||
vpc,
|
||||
securityGroupName: `${p}-instance-sg`,
|
||||
description: `${p} instance SG - ingress only from the shared ALB SG on :80; egress via NAT.`,
|
||||
allowAllOutbound: true,
|
||||
});
|
||||
instanceSg.addIngressRule(
|
||||
ec2.Peer.securityGroupId(SHARED.albSecurityGroupId),
|
||||
ec2.Port.tcp(80),
|
||||
`${p}: shared ALB SG to nginx :80`,
|
||||
);
|
||||
// Open the IMPORTED ALB SG to our instance via a STANDALONE egress rule, so we
|
||||
// never mutate the ALB SG's own (on-prem-owned) definition.
|
||||
new ec2.CfnSecurityGroupEgress(this, "AlbToInstanceEgress", {
|
||||
groupId: SHARED.albSecurityGroupId,
|
||||
ipProtocol: "tcp",
|
||||
fromPort: 80,
|
||||
toPort: 80,
|
||||
destinationSecurityGroupId: instanceSg.securityGroupId,
|
||||
description: `${p}: ALB to instance nginx :80`,
|
||||
});
|
||||
|
||||
// The app-deploy procedure (deploy/ami/deploy.sh) is a normal reviewable repo
|
||||
// file; CDK base64-encodes it (single line - no `$`/regex-special chars in the
|
||||
// base64 alphabet) and renders it into user-data's @@DEPLOY_SH_B64@@ token, so
|
||||
// user-data writes it verbatim to /opt/open-swe/bin/deploy.sh at first boot.
|
||||
// The same file is run by the open-swe-<env>-deploy SSM document on every
|
||||
// release - a single source of truth for "pull release, build venv, restart".
|
||||
const deployShPath = path.join(__dirname, "..", "..", "..", "deploy", "ami", "deploy.sh");
|
||||
// Minify before embedding: strip full-line comments + blank lines (keep the
|
||||
// shebang) so the base64 fits EC2's 25.6 KB user-data limit. The repo file
|
||||
// keeps its comments; only the on-box copy is minified. deploy.sh becomes
|
||||
// opaque base64 here, so this never affects user-data's heredoc parsing.
|
||||
const deployShMin = fs
|
||||
.readFileSync(deployShPath, "utf8")
|
||||
.split("\n")
|
||||
.filter((line, i) => i === 0 || (!/^\s*#/.test(line) && line.trim() !== ""))
|
||||
.join("\n");
|
||||
const deployShB64 = Buffer.from(deployShMin, "utf8").toString("base64");
|
||||
|
||||
// Render the provisioning script's @@tokens@@ into the instance user-data.
|
||||
// userDataCausesReplacement makes a bootstrap change roll a fresh box (the box
|
||||
// holds no durable state - see ami-cache.ts / user-data.sh). Editing deploy.sh
|
||||
// therefore also rolls the box (its base64 is embedded here) - acceptable: the
|
||||
// box is replacement-tolerant, and ongoing releases never touch user-data.
|
||||
const userDataPath = path.join(__dirname, "..", "..", "..", "deploy", "ami", "user-data.sh");
|
||||
const userData = ec2.UserData.custom(
|
||||
fs
|
||||
.readFileSync(userDataPath, "utf8")
|
||||
// %%...%% tokens are CDK-substituted here; they are DELIBERATELY a
|
||||
// different delimiter from the @@...@@ tokens user-data.sh seds into the
|
||||
// baked systemd/nginx templates, so CDK can never clobber a sed pattern
|
||||
// (a shared @@OPENSWE_ENV@@/@@SERVER_NAME@@ left the unit unsubstituted).
|
||||
.replace(/%%OPENSWE_ENV%%/g, env)
|
||||
.replace(/%%ASSETS_BUCKET%%/g, `${p}-assets`)
|
||||
.replace(/%%SERVER_NAME%%/g, net.dashboardHost)
|
||||
.replace(/%%ARTIFACT_PREFIX%%/g, artifactPrefix)
|
||||
.replace(/%%DEPLOY_SH_B64%%/g, deployShB64),
|
||||
);
|
||||
|
||||
this.instance = new ec2.Instance(this, "Instance", {
|
||||
vpc,
|
||||
vpcSubnets: {
|
||||
subnets: [
|
||||
ec2.Subnet.fromSubnetAttributes(this, "InstanceSubnet", {
|
||||
subnetId: SHARED.instanceSubnetId,
|
||||
availabilityZone: SHARED.instanceAz,
|
||||
}),
|
||||
],
|
||||
},
|
||||
instanceType: new ec2.InstanceType(net.instanceType),
|
||||
// The baked open-swe base AMI (deploy/ami packer build) - ARM64 Ubuntu 24.04
|
||||
// with the /opt/open-swe layout, openswe user, nginx, and CW agent that
|
||||
// user-data.sh assumes. Pinned by exact id (see ami-cache.ts); refresh by
|
||||
// rebuilding and updating BAKED_OPEN_SWE_AMI_ID.
|
||||
machineImage: bakedOpenSweArm64(),
|
||||
role: props.instanceRole,
|
||||
securityGroup: instanceSg,
|
||||
userData,
|
||||
userDataCausesReplacement: true,
|
||||
requireImdsv2: true,
|
||||
instanceName: `${p}-box`,
|
||||
blockDevices: [
|
||||
{
|
||||
deviceName: "/dev/xvda",
|
||||
// gp3 encrypted root; deleteOnTermination (no durable on-box state ->
|
||||
// intentionally NO standalone RETAIN volume; see ami-cache.ts).
|
||||
volume: ec2.BlockDeviceVolume.ebs(30, {
|
||||
volumeType: ec2.EbsDeviceVolumeType.GP3,
|
||||
encrypted: true,
|
||||
deleteOnTermination: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// SSM deploy document (open-swe-<env>-deploy): runs the baked
|
||||
// /opt/open-swe/bin/deploy.sh to pull the latest release + restart. CI fires it
|
||||
// (tag-scoped to project=open-swe,env=<env>) after uploading a release, so the
|
||||
// app deploy role needs SendCommand ONLY on this document - NOT on the generic
|
||||
// AWS-RunShellScript (closes the T4 BLOCK#3 arbitrary-shell timebox).
|
||||
this.deployDocumentName = `${p}-deploy`;
|
||||
new ssm.CfnDocument(this, "DeployDoc", {
|
||||
name: this.deployDocumentName,
|
||||
documentType: "Command",
|
||||
documentFormat: "YAML",
|
||||
updateMethod: "NewVersion",
|
||||
content: {
|
||||
schemaVersion: "2.2",
|
||||
description: `Roll the ${p} box to the latest published release (runs /opt/open-swe/bin/deploy.sh).`,
|
||||
mainSteps: [
|
||||
{
|
||||
action: "aws:runShellScript",
|
||||
name: "deploy",
|
||||
inputs: {
|
||||
// Fixed command - no parameters, so nothing untrusted is interpolated
|
||||
// into the shell. The script itself reads /etc/open-swe/boot.env.
|
||||
runCommand: ["bash /opt/open-swe/bin/deploy.sh"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Target group -> instance:80 (nginx). Health check hits nginx's /healthz
|
||||
// (returns 200; the dashboard TG health path defined in open-swe.nginx.conf).
|
||||
this.targetGroup = new elbv2.ApplicationTargetGroup(this, "Tg", {
|
||||
vpc,
|
||||
targetGroupName: `${p}-tg`,
|
||||
port: 80,
|
||||
protocol: elbv2.ApplicationProtocol.HTTP,
|
||||
targetType: elbv2.TargetType.INSTANCE,
|
||||
targets: [new elbTargets.InstanceTarget(this.instance)],
|
||||
deregistrationDelay: cdk.Duration.seconds(15),
|
||||
healthCheck: {
|
||||
path: "/healthz",
|
||||
healthyHttpCodes: "200",
|
||||
interval: cdk.Duration.seconds(30),
|
||||
timeout: cdk.Duration.seconds(5),
|
||||
healthyThresholdCount: 2,
|
||||
unhealthyThresholdCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Import the shared :443 listener (with its ALB SG) and ADD our two rules.
|
||||
const albSg = ec2.SecurityGroup.fromSecurityGroupId(this, "AlbSg", SHARED.albSecurityGroupId, {
|
||||
mutable: false,
|
||||
});
|
||||
const listener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, "HttpsListener", {
|
||||
listenerArn: SHARED.httpsListenerArn,
|
||||
securityGroup: albSg,
|
||||
});
|
||||
|
||||
// (1) Webhooks - accepted on EITHER host (integrations may target either), and
|
||||
// MUST be below the on-prem path-only rule 5 (see ENV_NET note).
|
||||
new elbv2.ApplicationListenerRule(this, "WebhooksRule", {
|
||||
listener,
|
||||
priority: net.webhookPriority,
|
||||
conditions: [
|
||||
elbv2.ListenerCondition.hostHeaders([net.dashboardHost, net.hooksHost]),
|
||||
elbv2.ListenerCondition.pathPatterns(["/webhooks/*"]),
|
||||
],
|
||||
action: elbv2.ListenerAction.forward([this.targetGroup]),
|
||||
});
|
||||
// (2) Dashboard SPA + /dashboard/api/ (OAuth) - DASHBOARD host ONLY. The hooks
|
||||
// host intentionally serves nothing but /webhooks/* (rule 1), so the OAuth /
|
||||
// dashboard surface stays single-origin (OSWE-T12-02). Non-webhook paths on the
|
||||
// hooks host fall through to the on-prem default.
|
||||
new elbv2.ApplicationListenerRule(this, "SiteRule", {
|
||||
listener,
|
||||
priority: net.sitePriority,
|
||||
conditions: [elbv2.ListenerCondition.hostHeaders([net.dashboardHost])],
|
||||
action: elbv2.ListenerAction.forward([this.targetGroup]),
|
||||
});
|
||||
|
||||
// Route53 ALIAS records -> the shared ALB, for both hostnames.
|
||||
const zone = route53.HostedZone.fromHostedZoneAttributes(this, "PublicZone", {
|
||||
hostedZoneId: SHARED.publicZoneId,
|
||||
zoneName: SHARED.publicZoneName,
|
||||
});
|
||||
const albAlias: route53.IAliasRecordTarget = {
|
||||
bind: () => ({
|
||||
dnsName: SHARED.albDnsName,
|
||||
hostedZoneId: SHARED.albCanonicalHostedZoneId,
|
||||
}),
|
||||
};
|
||||
for (const [label, host] of [
|
||||
["Dashboard", net.dashboardHost],
|
||||
["Hooks", net.hooksHost],
|
||||
] as const) {
|
||||
new route53.ARecord(this, `${label}Alias`, {
|
||||
zone,
|
||||
recordName: host,
|
||||
target: route53.RecordTarget.fromAlias(albAlias),
|
||||
comment: `${p} ${label.toLowerCase()} -> shared seahaven-com ALB`,
|
||||
});
|
||||
}
|
||||
|
||||
// IaC-owned CloudWatch log groups at 30-day retention. Names mirror the
|
||||
// CloudWatch-agent config (deploy/ami/templates/amazon-cloudwatch-agent.json);
|
||||
// owning them here makes retention declarative rather than agent-set. Logs are
|
||||
// not durable state -> DESTROY on stack delete.
|
||||
for (const suffix of ["app", "user-data", "nginx-access", "nginx-error"]) {
|
||||
new logs.LogGroup(this, `Log-${suffix}`, {
|
||||
logGroupName: `/open-swe/${env}/${suffix}`,
|
||||
retention: logs.RetentionDays.ONE_MONTH,
|
||||
removalPolicy: cdk.RemovalPolicy.DESTROY,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
58
infra/lib/constructs/assets-bucket.ts
Normal file
58
infra/lib/constructs/assets-bucket.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import * as s3 from "aws-cdk-lib/aws-s3";
|
||||
import { Construct } from "constructs";
|
||||
import { EnvName, prefix } from "../config";
|
||||
|
||||
/**
|
||||
* The per-env S3 artifact bucket (`open-swe-<env>-assets`) the box pulls its
|
||||
* release from (T7). CI builds the SPA + packages the app source and uploads
|
||||
* `app.tar.gz` / `spa.tar.gz` under `releases/<sha>/` + `releases/latest/`
|
||||
* (`build-artifacts.yml`, via the `githubdeploy-open-swe-app-<env>` OIDC role);
|
||||
* the box pulls `releases/latest/*` at boot / on deploy via its instance role.
|
||||
*
|
||||
* The bucket holds ONLY build artifacts — no secrets (those live in Secrets
|
||||
* Manager + SSM), no durable runtime state (the langgraph store is in-memory and
|
||||
* rebuilt every boot). It is therefore safe to treat as reproducible-from-CI, but
|
||||
* we RETAIN it on stack delete so an accidental `cdk destroy` cannot strand the
|
||||
* box with no artifact to pull on its next replacement.
|
||||
*
|
||||
* Security posture (locked, reviewed in T7):
|
||||
* - `BLOCK_ALL` public access (this is an internal artifact store; ALB/nginx is
|
||||
* the only public surface — never S3 directly).
|
||||
* - SSE-S3 encryption at rest + `enforceSSL` (deny any non-TLS request).
|
||||
* - versioned, so a bad release can be rolled back to the previous object
|
||||
* version (the last-good-artifact story in T19); a lifecycle rule expires
|
||||
* NONcurrent versions after 30 days so history does not grow unbounded.
|
||||
* - aborts incomplete multipart uploads after 7 days (cost hygiene).
|
||||
*
|
||||
* The name is the load-bearing contract: `instance-role.ts` (read), the app
|
||||
* deploy role in `github-deploy-roles.ts` (write), and `user-data.sh` /
|
||||
* `deploy.sh` (`@@ASSETS_BUCKET@@`) all reference `open-swe-<env>-assets` by
|
||||
* literal name, so it is set explicitly here rather than auto-generated.
|
||||
*/
|
||||
export class AssetsBucket extends Construct {
|
||||
public readonly bucket: s3.Bucket;
|
||||
|
||||
constructor(scope: Construct, id: string, envName: EnvName) {
|
||||
super(scope, id);
|
||||
const p = prefix(envName);
|
||||
|
||||
this.bucket = new s3.Bucket(this, "Bucket", {
|
||||
bucketName: `${p}-assets`,
|
||||
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
|
||||
encryption: s3.BucketEncryption.S3_MANAGED,
|
||||
enforceSSL: true,
|
||||
versioned: true,
|
||||
// Artifacts are reproducible from CI, but RETAIN protects against an
|
||||
// accidental stack delete leaving the box with nothing to pull (see above).
|
||||
removalPolicy: cdk.RemovalPolicy.RETAIN,
|
||||
lifecycleRules: [
|
||||
{
|
||||
id: "expire-noncurrent-artifact-versions",
|
||||
noncurrentVersionExpiration: cdk.Duration.days(30),
|
||||
abortIncompleteMultipartUploadAfter: cdk.Duration.days(7),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
247
infra/lib/constructs/config-store.ts
Normal file
247
infra/lib/constructs/config-store.ts
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
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.
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
161
infra/lib/constructs/github-deploy-roles.ts
Normal file
161
infra/lib/constructs/github-deploy-roles.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import { Construct } from "constructs";
|
||||
import {
|
||||
ACCOUNT,
|
||||
EnvName,
|
||||
GITHUB_OIDC_PROVIDER_ARN,
|
||||
REGION,
|
||||
oidcSubject,
|
||||
} from "../config";
|
||||
|
||||
/**
|
||||
* Per-ENV GitHub Actions OIDC deploy roles. Created ONCE per env in the
|
||||
* dedicated `open-swe-iam` stack. Two roles per env, per the locked architecture's
|
||||
* "dual OIDC roles":
|
||||
*
|
||||
* - githubdeploy-open-swe-infra-<env> → CFN/IAM (CDK) deploys of that env's stack
|
||||
* - githubdeploy-open-swe-app-<env> → app deploys (env-tag-scoped SSM + S3 read)
|
||||
*
|
||||
* T5 OSWE-IAC-01/02 fix: roles are split per env and the trust subject is
|
||||
* env-scoped (dev = dev branch ref; prod = the GitHub `prod` Environment subject,
|
||||
* so the manual-approval gate is IAM-enforced). A dev-branch token therefore
|
||||
* cannot SendCommand to the prod box nor assume a prod deploy role.
|
||||
*
|
||||
* Reviewed at T4 (GPT-4.1 IAM cross-review) + T5 (/sh-security-review) and
|
||||
* deployed FIRST (BLOCK#3 "OIDC-role-first" ordering) before any other infra or
|
||||
* secrets CI step.
|
||||
*/
|
||||
export class GithubDeployRoles extends Construct {
|
||||
public readonly infraRole: iam.Role;
|
||||
public readonly appRole: iam.Role;
|
||||
|
||||
constructor(scope: Construct, id: string, envName: EnvName) {
|
||||
super(scope, id);
|
||||
|
||||
// The provider already exists account-wide — reference, never re-create.
|
||||
const provider = iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn(
|
||||
this,
|
||||
"GithubOidcProvider",
|
||||
GITHUB_OIDC_PROVIDER_ARN,
|
||||
);
|
||||
|
||||
// T4 BLOCK#2 + T5 IAC-01/02: exact env-scoped subject via StringEquals (no
|
||||
// StringLike, no `*`). prod = environment:prod (manual-approval gate),
|
||||
// dev = the dev branch ref.
|
||||
const trust = new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, {
|
||||
StringEquals: {
|
||||
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
|
||||
"token.actions.githubusercontent.com:sub": oidcSubject(envName),
|
||||
},
|
||||
});
|
||||
|
||||
// ---- githubdeploy-open-swe-infra-<env> --------------------------------
|
||||
this.infraRole = new iam.Role(this, "InfraDeployRole", {
|
||||
roleName: `githubdeploy-open-swe-infra-${envName}`,
|
||||
assumedBy: trust,
|
||||
description: `GitHub OIDC role for CDK deploys of the open-swe-${envName} infra stack (assumes CDK bootstrap roles).`,
|
||||
});
|
||||
|
||||
// Org-standard CDK deploy pattern (mirrors githubdeploy-seahaven-account-
|
||||
// baseline / -forgejo / -apm-wo-analysis): the deploy role only needs to
|
||||
// assume the CDK bootstrap roles. The actual CloudFormation + IAM + resource
|
||||
// permissions are exercised by the bootstrap `cfn-exec-role`, whose scope is
|
||||
// owned by the CDKToolkit stack — NOT granted directly here.
|
||||
//
|
||||
// T4 BLOCK#1: GPT-4.1 flagged the `cdk-hnb659fds-*` wildcard and recommended
|
||||
// enumerating the four exact ARNs. ACCEPTED EXCEPTION (Adam, 2026-06-26): kept
|
||||
// as the verified org-wide convention (githubdeploy-seahaven-account-baseline
|
||||
// uses the identical wildcard). Only `cdk bootstrap` creates roles with this
|
||||
// prefix, so practical escalation risk is low.
|
||||
// T5 residual (OSWE-IAC-02): the single account-wide cfn-exec-role means the
|
||||
// dev infra role can technically deploy any stack; per-env trust gates WHO can
|
||||
// assume, and the prod role requires the environment:prod approval. Per-env
|
||||
// bootstrap qualifiers would close the residual fully (future hardening).
|
||||
this.infraRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "AssumeCdkBootstrapRoles",
|
||||
actions: ["sts:AssumeRole"],
|
||||
resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-hnb659fds-*`],
|
||||
}),
|
||||
);
|
||||
|
||||
// ---- githubdeploy-open-swe-app-<env> ----------------------------------
|
||||
this.appRole = new iam.Role(this, "AppDeployRole", {
|
||||
roleName: `githubdeploy-open-swe-app-${envName}`,
|
||||
assumedBy: trust,
|
||||
description: `GitHub OIDC role for open-swe-${envName} app deploys: env-tag-scoped ssm:SendCommand + read of the ${envName} S3 artifact bucket.`,
|
||||
});
|
||||
|
||||
// T5 OSWE-IAC-01 fix: SendCommand only to instances tagged project=open-swe
|
||||
// AND env=<this env> (a SINGLE value, not {dev,prod}). The dev app role can
|
||||
// never command the prod box and vice versa — env isolation in IAM.
|
||||
this.appRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "SsmSendCommandTagScoped",
|
||||
actions: ["ssm:SendCommand"],
|
||||
resources: [`arn:aws:ec2:${REGION}:${ACCOUNT}:instance/*`],
|
||||
conditions: {
|
||||
StringEquals: {
|
||||
"ssm:resourceTag/project": "open-swe",
|
||||
"ssm:resourceTag/env": envName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// SendCommand also has to reference the command document. Scope to this env's
|
||||
// open-swe deploy document ONLY.
|
||||
// T4 BLOCK#3 (CLOSED at T19): GPT-4.1 flagged AWS-RunShellScript as an
|
||||
// arbitrary-shell escalation path. The dedicated `open-swe-${envName}-deploy`
|
||||
// SSM document (app-service.ts) now runs the fixed, parameter-less command
|
||||
// `bash /opt/open-swe/bin/deploy.sh`, so AWS-RunShellScript is dropped here:
|
||||
// this role can run ONLY that one document, and only on its own env's box
|
||||
// (tag-scoped by the SsmSendCommandTagScoped statement above).
|
||||
this.appRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "SsmSendCommandDocuments",
|
||||
actions: ["ssm:SendCommand"],
|
||||
resources: [`arn:aws:ssm:${REGION}:${ACCOUNT}:document/open-swe-${envName}-deploy`],
|
||||
}),
|
||||
);
|
||||
|
||||
// Poll command results. These read actions do not support resource-level
|
||||
// scoping, so `*` is required by the API (T4 FIX: API limitation, documented).
|
||||
this.appRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "SsmReadCommandStatus",
|
||||
actions: [
|
||||
"ssm:GetCommandInvocation",
|
||||
"ssm:ListCommands",
|
||||
"ssm:ListCommandInvocations",
|
||||
],
|
||||
resources: ["*"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Read+WRITE access to THIS env's artifact bucket only (T19): the
|
||||
// build-artifacts workflow uploads app.tar.gz / spa.tar.gz under releases/*,
|
||||
// then fires the deploy document so the box pulls them via its instance role.
|
||||
// Object actions are scoped to releases/* (the only prefix CI writes), and to
|
||||
// THIS env's bucket — a dev token can never write the prod bucket. No
|
||||
// bucket-level mutation (no PutBucket*/Delete bucket) — that stays with CDK.
|
||||
// GetObject + PutObject (S3-to-S3 copy = Get source + Put dest) is all the
|
||||
// publish/rollback path uses; s3:DeleteObject is deliberately NOT granted so a
|
||||
// CI token cannot erase an immutable release or the releases/last-good rollback
|
||||
// fallback (lifecycle expiry handles old-version cleanup, not CI).
|
||||
this.appRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ReadWriteArtifactObjects",
|
||||
actions: ["s3:GetObject", "s3:PutObject"],
|
||||
resources: [`arn:aws:s3:::open-swe-${envName}-assets/releases/*`],
|
||||
}),
|
||||
);
|
||||
this.appRole.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ListArtifactBucket",
|
||||
actions: ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
resources: [`arn:aws:s3:::open-swe-${envName}-assets`],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
119
infra/lib/constructs/instance-role.ts
Normal file
119
infra/lib/constructs/instance-role.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import * as iam from "aws-cdk-lib/aws-iam";
|
||||
import { Construct } from "constructs";
|
||||
import { ACCOUNT, EnvName, REGION, prefix } from "../config";
|
||||
|
||||
/**
|
||||
* Least-privilege EC2 instance role for the open-swe box (one per env).
|
||||
*
|
||||
* Grants exactly what the boot/runtime flow needs and NOTHING ELSE — no admin,
|
||||
* no `*` resources except where the AWS action genuinely has no resource-level
|
||||
* scoping. Per-env so the dev box can never read prod secrets/config and vice
|
||||
* versa. Reviewed at T4 (GPT-4.1 IAM cross-review) / T5 (/sh-security-review)
|
||||
* before it is ever deployed (T6).
|
||||
*/
|
||||
export class InstanceRole extends Construct {
|
||||
public readonly role: iam.Role;
|
||||
|
||||
constructor(scope: Construct, id: string, env: EnvName) {
|
||||
super(scope, id);
|
||||
const p = prefix(env);
|
||||
|
||||
this.role = new iam.Role(this, "Role", {
|
||||
roleName: `${p}-instance-role`,
|
||||
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
|
||||
description: `EC2 instance role for the ${p} open-swe box (least-privilege).`,
|
||||
});
|
||||
|
||||
// AWS-managed: lets the SSM agent register the instance and RECEIVE the
|
||||
// app-deploy `ssm:SendCommand` from githubdeploy-open-swe-app. This is the
|
||||
// standard Session-Manager / RunCommand grant and is the only managed
|
||||
// policy on the role. DELIBERATE — flag for T4 confirmation.
|
||||
this.role.addManagedPolicy(
|
||||
iam.ManagedPolicy.fromAwsManagedPolicyName("AmazonSSMManagedInstanceCore"),
|
||||
);
|
||||
|
||||
// Read the build artifact from the env's S3 asset bucket (deploy = pull).
|
||||
// Scoped to releases/* — the only prefix CI writes and the box pulls — so a
|
||||
// compromised box (or stolen IMDS creds) cannot read anything else that might
|
||||
// ever land in the bucket (least-privilege; mirrors the app role's write scope).
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ReadArtifactObjects",
|
||||
actions: ["s3:GetObject"],
|
||||
resources: [`arn:aws:s3:::${p}-assets/releases/*`],
|
||||
}),
|
||||
);
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ListArtifactBucket",
|
||||
actions: ["s3:ListBucket", "s3:GetBucketLocation"],
|
||||
resources: [`arn:aws:s3:::${p}-assets`],
|
||||
}),
|
||||
);
|
||||
|
||||
// Read non-sensitive config from SSM Parameter Store under /open-swe-<env>/*.
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ReadSsmConfig",
|
||||
actions: ["ssm:GetParameter", "ssm:GetParameters", "ssm:GetParametersByPath"],
|
||||
resources: [`arn:aws:ssm:${REGION}:${ACCOUNT}:parameter/${p}/*`],
|
||||
}),
|
||||
);
|
||||
|
||||
// VALUE access — Secrets Manager under open-swe-<env>/*. Secret ARNs carry a
|
||||
// random 6-char suffix, hence the trailing `*`. This is the statement that
|
||||
// actually gates which secret VALUES the box can read: prefix-scoped, so the
|
||||
// dev box can never read prod secret values (and vice versa). GetSecretValue is
|
||||
// checked per-secret even when the value is returned via the batch call below.
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "ReadSecretValues",
|
||||
actions: ["secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret"],
|
||||
resources: [`arn:aws:secretsmanager:${REGION}:${ACCOUNT}:secret:${p}/*`],
|
||||
}),
|
||||
);
|
||||
|
||||
// BatchGetSecretValue MUST be granted on `*` — it is a collection action that
|
||||
// AWS authorizes against the account, NOT the per-secret ARN, REGARDLESS of
|
||||
// whether the caller uses `--filters` or `--secret-id-list`. A prefix-scoped
|
||||
// BatchGetSecretValue AccessDenies the whole call ("no identity-based policy
|
||||
// allows the secretsmanager:BatchGetSecretValue action") — VERIFIED on the live
|
||||
// dev box 2026-06-29 (the OSWE-IAC-SECRETS-LIST-01 attempt to prefix-scope it
|
||||
// crash-looped the box once the prior broad grant's eventual-consistency lapsed).
|
||||
// This `*` does NOT widen VALUE access: a secret value is only returned when the
|
||||
// prefix-scoped GetSecretValue above also allows it, so cross-env value isolation
|
||||
// holds. The win that DID survive: fetch-config uses `--secret-id-list` (explicit
|
||||
// names, no name filter), so `secretsmanager:ListSecrets` is NOT needed and is
|
||||
// intentionally omitted — the box cannot enumerate secret names account-wide.
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "BatchGetSecretValues",
|
||||
actions: ["secretsmanager:BatchGetSecretValue"],
|
||||
resources: ["*"],
|
||||
}),
|
||||
);
|
||||
|
||||
// NOTE (T11): SSM SecureString + Secrets Manager here are assumed to use the
|
||||
// AWS-managed keys (alias/aws/ssm, alias/aws/secretsmanager) for which the
|
||||
// service grants Decrypt implicitly — so NO kms:Decrypt is granted. If T11
|
||||
// moves these to a customer CMK, add a scoped `kms:Decrypt` on that key ARN
|
||||
// ONLY (not `*`).
|
||||
|
||||
// Ship application logs to CloudWatch Logs under /open-swe/<env>/*.
|
||||
this.role.addToPolicy(
|
||||
new iam.PolicyStatement({
|
||||
sid: "PutAppLogs",
|
||||
actions: [
|
||||
"logs:CreateLogGroup",
|
||||
"logs:CreateLogStream",
|
||||
"logs:PutLogEvents",
|
||||
"logs:DescribeLogStreams",
|
||||
],
|
||||
resources: [
|
||||
`arn:aws:logs:${REGION}:${ACCOUNT}:log-group:/open-swe/${env}/*`,
|
||||
`arn:aws:logs:${REGION}:${ACCOUNT}:log-group:/open-swe/${env}/*:*`,
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
38
infra/lib/open-swe-iam-stack.ts
Normal file
38
infra/lib/open-swe-iam-stack.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import { GithubDeployRoles } from "./constructs/github-deploy-roles";
|
||||
|
||||
/**
|
||||
* Account-level IAM stack: the per-ENV GitHub OIDC deploy roles
|
||||
* (githubdeploy-open-swe-{infra,app}-{dev,prod} — four roles).
|
||||
*
|
||||
* T5 OSWE-IAC-01/02 fix: roles are split per env with env-scoped OIDC trust, so
|
||||
* a dev-branch token cannot reach prod (prod roles require the GitHub
|
||||
* `prod` Environment manual-approval gate). They live in this dedicated stack
|
||||
* rather than the env stacks because IAM roles are global and this stack ships
|
||||
* FIRST (TODO.md BLOCK#3): the infra OIDC roles + the repo deploy-role-ARN
|
||||
* secrets must exist before any infra/secrets CI step. Synth-only until the
|
||||
* Phase-1 security gate (T4 + T5) clears (T6).
|
||||
*/
|
||||
export class OpenSweIamStack extends cdk.Stack {
|
||||
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const dev = new GithubDeployRoles(this, "DeployRolesDev", "dev");
|
||||
const prod = new GithubDeployRoles(this, "DeployRolesProd", "prod");
|
||||
|
||||
cdk.Tags.of(this).add("project", "open-swe");
|
||||
cdk.Tags.of(this).add("ManagedBy", "cdk");
|
||||
|
||||
const out = (id: string, role: { roleName?: string }, env: string, kind: string) =>
|
||||
new cdk.CfnOutput(this, id, {
|
||||
value: `arn:aws:iam::${this.account}:role/${role.roleName}`,
|
||||
description: `OIDC role ARN for ${env} ${kind} deploys — set as the ${env} deploy-role secret.`,
|
||||
});
|
||||
|
||||
out("InfraDeployRoleDevArn", dev.infraRole, "dev", "infra (CDK)");
|
||||
out("AppDeployRoleDevArn", dev.appRole, "dev", "app (tag-scoped SSM + S3)");
|
||||
out("InfraDeployRoleProdArn", prod.infraRole, "prod", "infra (CDK)");
|
||||
out("AppDeployRoleProdArn", prod.appRole, "prod", "app (tag-scoped SSM + S3)");
|
||||
}
|
||||
}
|
||||
90
infra/lib/open-swe-stack.ts
Normal file
90
infra/lib/open-swe-stack.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import { Construct } from "constructs";
|
||||
import { EnvName, prefix } from "./config";
|
||||
import { AppService } from "./constructs/app-service";
|
||||
import { AssetsBucket } from "./constructs/assets-bucket";
|
||||
import { ConfigStore } from "./constructs/config-store";
|
||||
import { InstanceRole } from "./constructs/instance-role";
|
||||
import { BAKED_OPEN_SWE_AMI_ID } from "./constructs/ami-cache";
|
||||
|
||||
export interface OpenSweStackProps extends cdk.StackProps {
|
||||
/** open-swe environment — drives the `open-swe-<env>-*` resource naming. */
|
||||
readonly envName: EnvName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-env open-swe stack (`open-swe-dev` / `open-swe-prod`). Resource names are
|
||||
* prefixed `open-swe-<env>-*`.
|
||||
*
|
||||
* Composes: the per-env least-privilege instance role (T6), the Secrets/SSM
|
||||
* config store (T11), and the compute + ingress wiring (T12, AppService — EC2
|
||||
* box, instance SG, target group, imported-listener rules, Route53 aliases,
|
||||
* 30-day log groups). The shared VPC and ALB are imported, never owned. Synth is
|
||||
* offline (AMI is the cdk.context.json-pinned placeholder until T12-deploy).
|
||||
*/
|
||||
export class OpenSweStack extends cdk.Stack {
|
||||
public readonly instanceRole: InstanceRole;
|
||||
public readonly configStore: ConfigStore;
|
||||
public readonly assetsBucket: AssetsBucket;
|
||||
public readonly appService: AppService;
|
||||
|
||||
constructor(scope: Construct, id: string, props: OpenSweStackProps) {
|
||||
super(scope, id, props);
|
||||
|
||||
const envName = props.envName;
|
||||
const p = prefix(envName);
|
||||
|
||||
cdk.Tags.of(this).add("project", "open-swe");
|
||||
cdk.Tags.of(this).add("env", envName);
|
||||
cdk.Tags.of(this).add("ManagedBy", "cdk");
|
||||
|
||||
// Per-env least-privilege EC2 instance role (open-swe-<env>-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-<env>/* + /open-swe-<env>/*.
|
||||
this.configStore = new ConfigStore(this, "Config", { envName });
|
||||
|
||||
// T7: the S3 artifact bucket (open-swe-<env>-assets) CI uploads releases to
|
||||
// and the box pulls app.tar.gz / spa.tar.gz from. The instance role already
|
||||
// grants read on it by name; the app deploy role grants write.
|
||||
this.assetsBucket = new AssetsBucket(this, "Assets", envName);
|
||||
|
||||
// Surface the baked open-swe base AMI id the box runs on (pinned by id in
|
||||
// ami-cache.ts; refreshed by a deliberate packer rebuild → replacement).
|
||||
new cdk.CfnOutput(this, "BakedAmiId", {
|
||||
value: BAKED_OPEN_SWE_AMI_ID,
|
||||
description: "Baked open-swe-base-arm64 AMI id consumed by the EC2 instance.",
|
||||
});
|
||||
|
||||
// T12: compute + ingress. Imports the shared seahaven-vpc + ALB and adds the
|
||||
// env's EC2 box, instance SG, target group, listener rules, DNS, log groups.
|
||||
this.appService = new AppService(this, "App", {
|
||||
envName,
|
||||
instanceRole: this.instanceRole.role,
|
||||
});
|
||||
|
||||
new cdk.CfnOutput(this, "InstanceRoleArn", {
|
||||
value: this.instanceRole.role.roleArn,
|
||||
description: `${p} EC2 instance role ARN.`,
|
||||
});
|
||||
new cdk.CfnOutput(this, "InstanceId", {
|
||||
value: this.appService.instance.instanceId,
|
||||
description: `${p} EC2 instance id.`,
|
||||
});
|
||||
new cdk.CfnOutput(this, "TargetGroupArn", {
|
||||
value: this.appService.targetGroup.targetGroupArn,
|
||||
description: `${p} ALB target group ARN (→ instance:80 nginx).`,
|
||||
});
|
||||
new cdk.CfnOutput(this, "AssetsBucketName", {
|
||||
value: this.assetsBucket.bucket.bucketName,
|
||||
description: `${p} S3 artifact bucket (CI uploads releases; box pulls).`,
|
||||
});
|
||||
new cdk.CfnOutput(this, "DeployDocumentName", {
|
||||
value: this.appService.deployDocumentName,
|
||||
description: `${p} SSM document that rolls the box to the latest release.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
4487
infra/package-lock.json
generated
Normal file
4487
infra/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
31
infra/package.json
Normal file
31
infra/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "open-swe-infra",
|
||||
"version": "1.0.0",
|
||||
"description": "Open SWE AWS infrastructure (CDK TypeScript) — open-swe-dev / open-swe-prod stacks + shared OIDC deploy roles.",
|
||||
"private": true,
|
||||
"bin": {
|
||||
"open-swe-infra": "bin/app.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"cdk": "cdk",
|
||||
"synth": "cdk synth",
|
||||
"diff": "cdk diff",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"aws-cdk": "^2.1029.0",
|
||||
"jest": "^29.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws-cdk-lib": "2.260.0",
|
||||
"constructs": "^10.0.0"
|
||||
}
|
||||
}
|
||||
87
infra/test/ascii-aws-fields.test.ts
Normal file
87
infra/test/ascii-aws-fields.test.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
import { Template } from "aws-cdk-lib/assertions";
|
||||
import { OpenSweStack } from "../lib/open-swe-stack";
|
||||
|
||||
const ENV = { account: "328440206208", region: "us-east-1" };
|
||||
|
||||
/**
|
||||
* Several AWS APIs reject non-ASCII in fields that `cdk synth` happily emits and
|
||||
* `tsc` happily compiles — so a stray em-dash/arrow only blows up at DEPLOY time
|
||||
* (e.g. EC2 SecurityGroup GroupDescription: "Character sets beyond ASCII are not
|
||||
* supported"). This has bitten us twice (the AMI Description, then the instance-SG
|
||||
* description). This test fails the build at synth time instead.
|
||||
*
|
||||
* Scope: the EC2 fields with a documented ASCII/restricted-charset constraint —
|
||||
* SecurityGroup GroupDescription and ingress/egress rule descriptions. (CloudFormation
|
||||
* Output descriptions + Route53 comments accept UTF-8, so they are not asserted.)
|
||||
*
|
||||
* EC2 rule descriptions are stricter than ASCII: the allowed set is
|
||||
* `a-zA-Z0-9. _-:/()#,@[]+=&;{}!$*` — note it EXCLUDES `<` and `>`, which is why a
|
||||
* naive em-dash -> "->" replacement still fails at deploy. We assert that exact set.
|
||||
*/
|
||||
// Characters NOT in the EC2 description allowed set.
|
||||
const DISALLOWED = /[^a-zA-Z0-9. _:/()#,@[\]+=&;{}!$*-]/;
|
||||
|
||||
function synthDev(): Record<string, { Type: string; Properties?: Record<string, unknown> }> {
|
||||
const app = new cdk.App();
|
||||
const dev = new OpenSweStack(app, "OpenSweDevStack", {
|
||||
stackName: "open-swe-dev",
|
||||
env: ENV,
|
||||
envName: "dev",
|
||||
});
|
||||
return Template.fromStack(dev).toJSON().Resources;
|
||||
}
|
||||
|
||||
describe("ASCII-only EC2 description fields", () => {
|
||||
const resources = synthDev();
|
||||
|
||||
it("SecurityGroup GroupDescription is ASCII", () => {
|
||||
for (const [id, r] of Object.entries(resources)) {
|
||||
if (r.Type !== "AWS::EC2::SecurityGroup") continue;
|
||||
const desc = (r.Properties?.GroupDescription as string) ?? "";
|
||||
expect(DISALLOWED.test(desc) ? `${id}: ${desc}` : "ascii").toBe("ascii");
|
||||
}
|
||||
});
|
||||
|
||||
it("SecurityGroup ingress/egress rule descriptions are ASCII", () => {
|
||||
for (const [id, r] of Object.entries(resources)) {
|
||||
const props = r.Properties ?? {};
|
||||
const groups: Array<{ Description?: string }> = [];
|
||||
if (Array.isArray(props.SecurityGroupIngress)) groups.push(...props.SecurityGroupIngress);
|
||||
if (Array.isArray(props.SecurityGroupEgress)) groups.push(...props.SecurityGroupEgress);
|
||||
// Standalone AWS::EC2::SecurityGroupEgress / ...Ingress resources.
|
||||
if (r.Type === "AWS::EC2::SecurityGroupEgress" || r.Type === "AWS::EC2::SecurityGroupIngress") {
|
||||
groups.push(props as { Description?: string });
|
||||
}
|
||||
for (const rule of groups) {
|
||||
const desc = rule.Description ?? "";
|
||||
expect(DISALLOWED.test(desc) ? `${id}: ${desc}` : "ascii").toBe("ascii");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// EC2 caps base64-encoded user-data at 25600 bytes; CDK + tsc don't check it, so
|
||||
// an oversized boot script (e.g. an embedded deploy.sh) only fails at deploy.
|
||||
it("EC2 user-data fits the 25600-byte encoded limit", () => {
|
||||
for (const [id, r] of Object.entries(resources)) {
|
||||
if (r.Type !== "AWS::EC2::Instance") continue;
|
||||
const ud = (r.Properties?.UserData as { "Fn::Base64"?: string }) ?? {};
|
||||
const script = typeof ud["Fn::Base64"] === "string" ? ud["Fn::Base64"] : "";
|
||||
const encoded = Buffer.from(script, "utf8").toString("base64").length;
|
||||
expect(`${id}: ${encoded} bytes`).toBe(encoded < 25600 ? `${id}: ${encoded} bytes` : "OVER 25600");
|
||||
}
|
||||
});
|
||||
|
||||
// CDK substitutes %%...%% tokens in user-data at synth. Any %%TOKEN%% left in the
|
||||
// rendered script means a token wasn't wired in app-service.ts (the @@...@@ tokens
|
||||
// are intentional — user-data seds those into the baked templates at boot).
|
||||
it("user-data has no unresolved %%CDK%% tokens", () => {
|
||||
for (const [id, r] of Object.entries(resources)) {
|
||||
if (r.Type !== "AWS::EC2::Instance") continue;
|
||||
const ud = (r.Properties?.UserData as { "Fn::Base64"?: string }) ?? {};
|
||||
const script = typeof ud["Fn::Base64"] === "string" ? ud["Fn::Base64"] : "";
|
||||
const leftover = script.match(/%%[A-Z0-9_]+%%/g) ?? [];
|
||||
expect(`${id}: ${leftover.join(",")}`).toBe(`${id}: `);
|
||||
}
|
||||
});
|
||||
});
|
||||
109
infra/test/kebab-naming-aspect.test.ts
Normal file
109
infra/test/kebab-naming-aspect.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import * as cdk from "aws-cdk-lib";
|
||||
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";
|
||||
import { OpenSweStack } from "../lib/open-swe-stack";
|
||||
|
||||
const ENV = { account: "328440206208", region: "us-east-1" };
|
||||
|
||||
describe("isKebabCase", () => {
|
||||
it.each([
|
||||
"open-swe-dev",
|
||||
"open-swe-prod-instance-role",
|
||||
"githubdeploy-open-swe-infra",
|
||||
"open-swe-dev/slack-signing", // Secrets Manager path
|
||||
"/open-swe-dev/feature-flag", // SSM param path
|
||||
"/open-swe/dev/agent", // log group path
|
||||
"abc123",
|
||||
])("accepts conforming name %s", (name) => {
|
||||
expect(isKebabCase(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"OpenSweDev",
|
||||
"open_swe_dev",
|
||||
"openSweDev",
|
||||
"Open-Swe-Dev",
|
||||
"open-swe-dev/SlackSigning",
|
||||
])("rejects non-conforming name %s", (name) => {
|
||||
expect(isKebabCase(name)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("KebabNamingAspect", () => {
|
||||
it("passes the real app stacks (no errors)", () => {
|
||||
const app = new cdk.App();
|
||||
cdk.Aspects.of(app).add(new KebabNamingAspect());
|
||||
|
||||
new OpenSweIamStack(app, "OpenSweIamStack", { stackName: "open-swe-iam", env: ENV });
|
||||
const dev = new OpenSweStack(app, "OpenSweDevStack", {
|
||||
stackName: "open-swe-dev",
|
||||
env: ENV,
|
||||
envName: "dev",
|
||||
});
|
||||
const prod = new OpenSweStack(app, "OpenSweProdStack", {
|
||||
stackName: "open-swe-prod",
|
||||
env: ENV,
|
||||
envName: "prod",
|
||||
});
|
||||
|
||||
for (const s of [dev, prod]) {
|
||||
Annotations.fromStack(s).hasNoError("*", Match.anyValue());
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
cdk.Aspects.of(stack).add(new KebabNamingAspect());
|
||||
|
||||
// Deliberately bad physical name — must be flagged.
|
||||
new iam.Role(stack, "BadlyNamedRole", {
|
||||
roleName: "OpenSweBadRole",
|
||||
assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"),
|
||||
});
|
||||
|
||||
Annotations.fromStack(stack).hasError(
|
||||
"*",
|
||||
Match.stringLikeRegexp("not kebab-case"),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a deliberately non-kebab-case stack name", () => {
|
||||
const app = new cdk.App();
|
||||
// PascalCase stackName — the convention CDK defaults to and that we forbid.
|
||||
const stack = new cdk.Stack(app, "BadStack", { stackName: "OpenSweBadStack", env: ENV });
|
||||
cdk.Aspects.of(stack).add(new KebabNamingAspect());
|
||||
|
||||
Annotations.fromStack(stack).hasError(
|
||||
"*",
|
||||
Match.stringLikeRegexp("Stack name .* is not kebab-case"),
|
||||
);
|
||||
});
|
||||
});
|
||||
24
infra/tsconfig.json
Normal file
24
infra/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node", "jest"],
|
||||
"declaration": true,
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"outDir": "./cdk.out",
|
||||
"rootDir": ".",
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"exclude": ["node_modules", "cdk.out"]
|
||||
}
|
||||
|
|
@ -10,14 +10,14 @@ dependencies = [
|
|||
"fastapi>=0.136.3",
|
||||
"uvicorn>=0.48.0",
|
||||
"httpx>=0.28.1",
|
||||
"PyJWT>=2.12.1",
|
||||
"PyJWT>=2.13.0",
|
||||
"cryptography>=48.0.1",
|
||||
"langgraph-sdk>=0.4.2",
|
||||
"langchain>=1.3.9",
|
||||
"langgraph>=1.1.10",
|
||||
"markdownify>=1.2.2",
|
||||
"langchain-anthropic>=1.4.6",
|
||||
"langgraph-cli[inmem]>=0.4.27",
|
||||
"langgraph-cli[inmem]>=0.4.30",
|
||||
"langsmith==0.8.18",
|
||||
"langchain-openai>=1.2.2",
|
||||
"langchain-fireworks>=1.4.2",
|
||||
|
|
|
|||
|
|
@ -1,23 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from agent.utils.authorship import (
|
||||
OPEN_SWE_BOT_EMAIL,
|
||||
OPEN_SWE_BOT_NAME,
|
||||
add_bot_coauthor_trailer,
|
||||
resolve_triggering_user_identity,
|
||||
)
|
||||
|
||||
_BOT_TRAILER = f"Co-authored-by: {OPEN_SWE_BOT_NAME} <{OPEN_SWE_BOT_EMAIL}>"
|
||||
|
||||
|
||||
def test_add_bot_coauthor_trailer_appends_bot() -> None:
|
||||
result = add_bot_coauthor_trailer("fix: thing")
|
||||
assert result == f"fix: thing\n\n{_BOT_TRAILER}"
|
||||
|
||||
|
||||
def test_add_bot_coauthor_trailer_is_idempotent() -> None:
|
||||
once = add_bot_coauthor_trailer("fix: thing")
|
||||
assert add_bot_coauthor_trailer(once) == once
|
||||
from agent.utils.authorship import resolve_triggering_user_identity
|
||||
|
||||
|
||||
def test_resolve_identity_from_config_uses_user_noreply_email() -> None:
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from agent.utils.authorship import (
|
|||
OPEN_SWE_BOT_EMAIL,
|
||||
OPEN_SWE_BOT_NAME,
|
||||
CollaboratorIdentity,
|
||||
add_pr_collaboration_note,
|
||||
resolve_triggering_user_identity,
|
||||
)
|
||||
|
||||
|
|
@ -100,7 +99,7 @@ def test_construct_system_prompt_includes_corridor_prompt_when_enabled() -> None
|
|||
def test_construct_system_prompt_omits_collaboration_section_without_identity() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
assert "Collaborative Attribution" not in prompt
|
||||
assert "Authorship & Attribution" not in prompt
|
||||
assert "Co-authored-by:" not in prompt
|
||||
|
||||
|
||||
|
|
@ -136,7 +135,7 @@ def test_construct_system_prompt_forbids_force_push() -> None:
|
|||
assert "git pull --rebase origin <branch>" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_includes_coauthor_trailer_when_identity_present() -> None:
|
||||
def test_construct_system_prompt_emits_no_attribution_when_identity_present() -> None:
|
||||
identity = CollaboratorIdentity(
|
||||
display_name="octocat",
|
||||
commit_name="octocat",
|
||||
|
|
@ -148,16 +147,24 @@ def test_construct_system_prompt_includes_coauthor_trailer_when_identity_present
|
|||
triggering_user_identity=identity,
|
||||
)
|
||||
|
||||
assert "Collaborative Attribution" in prompt
|
||||
# The user authors the commits; open-swe[bot] is the co-author/collaborator.
|
||||
# The Sea Haven Authorship section renders, the commits are still authored as
|
||||
# the triggering user (identity flip to the bot is deferred — see issue #11),
|
||||
# and NO agent/AI attribution leaks into the prompt.
|
||||
assert "Authorship & Attribution" in prompt
|
||||
assert "Add NO agent or AI attribution" in prompt
|
||||
# Values are shell-escaped via shlex.quote; safe tokens need no quoting.
|
||||
assert "git config user.name octocat" in prompt
|
||||
assert "git config user.email 1234+octocat@users.noreply.github.com" in prompt
|
||||
assert _BOT_TRAILER in prompt
|
||||
assert "Made by [Open SWE](https://openswe.vercel.app)" in prompt
|
||||
# The concrete attribution artifacts the old template INSTRUCTED are gone (the
|
||||
# prohibition still names them as examples, so assert the instructional forms:
|
||||
# the full co-author trailer, the URL-bearing footer, and the old mandate text).
|
||||
assert _BOT_TRAILER not in prompt
|
||||
assert "Made by [Open SWE](https://openswe.vercel.app)" not in prompt
|
||||
assert "Credit open-swe as the collaborator" not in prompt
|
||||
assert "append this trailer" not in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_includes_github_login_in_pr_footer() -> None:
|
||||
def test_construct_system_prompt_no_attribution_with_github_login() -> None:
|
||||
identity = CollaboratorIdentity(
|
||||
display_name="Mona Lisa",
|
||||
commit_name="Mona Lisa",
|
||||
|
|
@ -173,27 +180,42 @@ def test_construct_system_prompt_includes_github_login_in_pr_footer() -> None:
|
|||
# A name with a space is shlex-quoted; the safe email is left bare.
|
||||
assert "git config user.name 'Mona Lisa'" in prompt
|
||||
assert "git config user.email 1234+octocat@users.noreply.github.com" in prompt
|
||||
assert _BOT_TRAILER in prompt
|
||||
assert "Made by [Open SWE](https://openswe.vercel.app)" in prompt
|
||||
assert "replace that existing footer with this line" in prompt
|
||||
assert "`_Opened collaboratively by Mona Lisa and open-swe._`" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_footer_links_thread_when_provided() -> None:
|
||||
identity = CollaboratorIdentity(
|
||||
display_name="octocat",
|
||||
commit_name="octocat",
|
||||
commit_email="1234+octocat@users.noreply.github.com",
|
||||
)
|
||||
|
||||
prompt = construct_system_prompt(
|
||||
working_dir="/workspace",
|
||||
triggering_user_identity=identity,
|
||||
thread_url="https://openswe.vercel.app/agents/abc-123",
|
||||
)
|
||||
|
||||
assert "Made by [Open SWE](https://openswe.vercel.app/agents/abc-123)" in prompt
|
||||
assert _BOT_TRAILER not in prompt
|
||||
assert "Made by [Open SWE](https://openswe.vercel.app)" not in prompt
|
||||
assert "replace that existing footer with this line" not in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_uses_sea_haven_conventions() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
# Branch naming, PR structure, and commit format follow the handbook.
|
||||
assert "feature/" in prompt and "hotfix/" in prompt
|
||||
assert "Do NOT use a conventional-commit `type:` prefix" in prompt
|
||||
assert "## Summary" in prompt and "## Validation" in prompt
|
||||
assert "## Release Note" not in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_links_resolved_github_issue() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
# Part A: PRs that resolve a GitHub issue auto-link it for auto-close.
|
||||
assert "Closes #<n>" in prompt
|
||||
assert "Refs #<n>" in prompt or "Part of #<n>" in prompt
|
||||
assert "Closes owner/repo#<n>" in prompt
|
||||
# The default-branch auto-close caveat must be stated so it isn't read as a bug.
|
||||
assert "default branch" in prompt
|
||||
assert "promoted" in prompt or "promotion" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_pr_title_rule_is_repo_aware() -> None:
|
||||
prompt = construct_system_prompt(working_dir="/workspace")
|
||||
|
||||
# Part B: detect a conventional-commit title gate and conform to it...
|
||||
assert "amannn/action-semantic-pull-request" in prompt
|
||||
assert "repo-aware" in prompt
|
||||
assert "type(scope): description" in prompt or "type(scope): …" in prompt
|
||||
# ...without dropping the no-prefix default for repos that don't enforce one.
|
||||
assert "Do NOT use a conventional-commit `type:` prefix" in prompt
|
||||
|
||||
|
||||
def test_construct_system_prompt_shell_escapes_user_name() -> None:
|
||||
|
|
@ -217,38 +239,6 @@ def test_construct_system_prompt_shell_escapes_user_name() -> None:
|
|||
assert f"git config user.name {hostile}" not in prompt
|
||||
|
||||
|
||||
def test_add_pr_collaboration_note_replaces_legacy_footer() -> None:
|
||||
identity = CollaboratorIdentity(
|
||||
display_name="Mona Lisa",
|
||||
commit_name="Mona Lisa",
|
||||
commit_email="1234+octocat@users.noreply.github.com",
|
||||
github_login="octocat",
|
||||
)
|
||||
|
||||
body = "## Description\nDone.\n\n_Opened collaboratively by Mona Lisa and open-swe._"
|
||||
|
||||
assert add_pr_collaboration_note(body, identity) == (
|
||||
"## Description\nDone.\n\nMade by [Open SWE](https://openswe.vercel.app)"
|
||||
)
|
||||
|
||||
|
||||
def test_add_pr_collaboration_note_links_thread() -> None:
|
||||
body = "## Description\nDone."
|
||||
|
||||
assert add_pr_collaboration_note(
|
||||
body, thread_url="https://openswe.vercel.app/agents/abc-123"
|
||||
) == ("## Description\nDone.\n\nMade by [Open SWE](https://openswe.vercel.app/agents/abc-123)")
|
||||
|
||||
|
||||
def test_add_pr_collaboration_note_skips_when_footer_present_with_other_link() -> None:
|
||||
body = "## Description\nDone.\n\nMade by [Open SWE](https://openswe.vercel.app)"
|
||||
|
||||
assert (
|
||||
add_pr_collaboration_note(body, thread_url="https://openswe.vercel.app/agents/abc-123")
|
||||
== body
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_triggering_user_identity_combines_slack_name_with_github_login() -> None:
|
||||
identity = resolve_triggering_user_identity(
|
||||
{
|
||||
|
|
|
|||
9386
ui/yarn.lock
9386
ui/yarn.lock
File diff suppressed because it is too large
Load diff
18
uv.lock
generated
18
uv.lock
generated
|
|
@ -1,5 +1,5 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14'",
|
||||
|
|
@ -1627,7 +1627,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "langgraph-cli"
|
||||
version = "0.4.27"
|
||||
version = "0.4.30"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
|
|
@ -1636,9 +1636,9 @@ dependencies = [
|
|||
{ name = "pathspec" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/02/21/046b2345d83427a59ebdac797fbe343bb8d1e575560667846f7346733774/langgraph_cli-0.4.27.tar.gz", hash = "sha256:67a64b67dddc8c670d77cc4c9663a7f8e924eaeb8857a926e87f239b699ef8f6", size = 1045499, upload-time = "2026-05-28T14:25:35.13Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/27/4b6a0f00c804f0b0831f741c0607b46a4cbddff14d1eab6bbd4ce5820837/langgraph_cli-0.4.30.tar.gz", hash = "sha256:4948fdc77ff45fc5ef3d8330d17bbecfcb26cd9c4d3a4f00da84a41a0226cd72", size = 1046771, upload-time = "2026-06-16T19:46:27.949Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/89/4bda72ff33172ad15ddb2c57c5b998bf9309e7c4890d3d84a8c8ed6333d4/langgraph_cli-0.4.27-py3-none-any.whl", hash = "sha256:d53bddfc4e7b6e47871bedb34ccc476fd5e4e97f8678dd453ecac597c5f11ff1", size = 78002, upload-time = "2026-05-28T14:25:34.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/b6/94cbd2ba0820caae203a915272394c576a21ab4a56dfbc93724dc8cd8e2b/langgraph_cli-0.4.30-py3-none-any.whl", hash = "sha256:9c577750c57da1a0e3407e8b83e5a0d7eaa80685fe99d95aa7f9bf0e1e73ca92", size = 82061, upload-time = "2026-06-16T19:46:26.873Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -2040,12 +2040,12 @@ requires-dist = [
|
|||
{ name = "langchain-openai", specifier = ">=1.2.2" },
|
||||
{ name = "langchain-runloop", specifier = ">=0.0.4" },
|
||||
{ name = "langgraph", specifier = ">=1.1.10" },
|
||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.27" },
|
||||
{ name = "langgraph-cli", extras = ["inmem"], specifier = ">=0.4.30" },
|
||||
{ name = "langgraph-sdk", specifier = ">=0.4.2" },
|
||||
{ name = "langsmith", specifier = "==0.8.18" },
|
||||
{ name = "markdownify", specifier = ">=1.2.2" },
|
||||
{ name = "pygments", marker = "extra == 'dev'", specifier = ">=2.20.0" },
|
||||
{ name = "pyjwt", specifier = ">=2.12.1" },
|
||||
{ name = "pyjwt", specifier = ">=2.13.0" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.4.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.15" },
|
||||
|
|
@ -2618,11 +2618,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.12.1"
|
||||
version = "2.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue