Compare commits

..

9 commits

Author SHA1 Message Date
dependabot[bot]
bdc2ae1325
Merge 4f88aa3147 into a4ed19ba61 2026-06-29 20:05:11 +00:00
dependabot[bot]
4f88aa3147
chore(deps): bump the minor-and-patch group across 1 directory with 17 updates
Bumps the minor-and-patch group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [deepagents](https://github.com/langchain-ai/deepagents) | `0.6.8` | `0.6.12` |
| [fastapi](https://github.com/fastapi/fastapi) | `0.136.3` | `0.138.2` |
| [uvicorn](https://github.com/Kludex/uvicorn) | `0.48.0` | `0.49.0` |
| [langsmith](https://github.com/langchain-ai/langsmith-sdk) | `0.8.18` | `0.9.3` |
| [langchain-openai](https://github.com/langchain-ai/langchain) | `1.2.2` | `1.3.3` |
| [langchain-fireworks](https://github.com/langchain-ai/langchain) | `1.4.2` | `1.4.3` |
| [langchain-daytona](https://github.com/langchain-ai/deepagents) | `0.0.6` | `0.0.7` |
| [langchain-modal](https://github.com/langchain-ai/deepagents) | `0.0.4` | `0.0.5` |
| [langchain-runloop](https://github.com/langchain-ai/deepagents) | `0.0.5` | `0.0.6` |
| [exa-py](https://github.com/exa-labs/exa-py) | `2.13.0` | `2.15.0` |
| [langchain-mcp-adapters](https://github.com/langchain-ai/langchain-mcp-adapters) | `0.2.2` | `0.3.0` |
| [pytest](https://github.com/pytest-dev/pytest) | `9.0.3` | `9.1.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.15` | `0.15.20` |



Updates `deepagents` from 0.6.8 to 0.6.12
- [Release notes](https://github.com/langchain-ai/deepagents/releases)
- [Commits](https://github.com/langchain-ai/deepagents/compare/deepagents==0.6.8...deepagents==0.6.12)

Updates `fastapi` from 0.136.3 to 0.138.2
- [Release notes](https://github.com/fastapi/fastapi/releases)
- [Commits](https://github.com/fastapi/fastapi/compare/0.136.3...0.138.2)

Updates `uvicorn` from 0.48.0 to 0.49.0
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](https://github.com/Kludex/uvicorn/compare/0.48.0...0.49.0)

Updates `langchain` from 1.3.9 to 1.3.11
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain==1.3.9...langchain==1.3.11)

Updates `langgraph` from 1.2.4 to 1.2.6
- [Release notes](https://github.com/langchain-ai/langgraph/releases)
- [Commits](https://github.com/langchain-ai/langgraph/compare/1.2.4...1.2.6)

Updates `langchain-anthropic` from 1.4.6 to 1.4.8
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-anthropic==1.4.6...langchain-anthropic==1.4.8)

Updates `langsmith` from 0.8.18 to 0.9.3
- [Release notes](https://github.com/langchain-ai/langsmith-sdk/releases)
- [Commits](https://github.com/langchain-ai/langsmith-sdk/compare/v0.8.18...v0.9.3)

Updates `langchain-openai` from 1.2.2 to 1.3.3
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-openai==1.2.2...langchain-openai==1.3.3)

Updates `langchain-fireworks` from 1.4.2 to 1.4.3
- [Release notes](https://github.com/langchain-ai/langchain/releases)
- [Commits](https://github.com/langchain-ai/langchain/compare/langchain-fireworks==1.4.2...langchain-fireworks==1.4.3)

Updates `langchain-daytona` from 0.0.6 to 0.0.7
- [Release notes](https://github.com/langchain-ai/deepagents/releases)
- [Commits](https://github.com/langchain-ai/deepagents/compare/langchain-daytona==0.0.6...langchain-daytona==0.0.7)

Updates `langchain-modal` from 0.0.4 to 0.0.5
- [Release notes](https://github.com/langchain-ai/deepagents/releases)
- [Commits](https://github.com/langchain-ai/deepagents/compare/langchain-modal==0.0.4...langchain-modal==0.0.5)

Updates `langchain-runloop` from 0.0.5 to 0.0.6
- [Release notes](https://github.com/langchain-ai/deepagents/releases)
- [Commits](https://github.com/langchain-ai/deepagents/compare/langchain-runloop==0.0.5...langchain-runloop==0.0.6)

Updates `exa-py` from 2.13.0 to 2.15.0
- [Release notes](https://github.com/exa-labs/exa-py/releases)
- [Changelog](https://github.com/exa-labs/exa-py/blob/master/CHANGELOG.md)
- [Commits](https://github.com/exa-labs/exa-py/commits)

Updates `langchain-google-genai` from 4.2.4 to 4.2.6
- [Release notes](https://github.com/langchain-ai/langchain-google/releases)
- [Commits](https://github.com/langchain-ai/langchain-google/compare/libs/genai/v4.2.4...libs/genai/v4.2.6)

Updates `langchain-mcp-adapters` from 0.2.2 to 0.3.0
- [Release notes](https://github.com/langchain-ai/langchain-mcp-adapters/releases)
- [Commits](https://github.com/langchain-ai/langchain-mcp-adapters/compare/langchain-mcp-adapters==0.2.2...langchain-mcp-adapters==0.3.0)

Updates `pytest` from 9.0.3 to 9.1.1
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.1)

Updates `ruff` from 0.15.15 to 0.15.20
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.20)

---
updated-dependencies:
- dependency-name: deepagents
  dependency-version: 0.6.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: exa-py
  dependency-version: 2.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: fastapi
  dependency-version: 0.138.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: langchain
  dependency-version: 1.3.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-anthropic
  dependency-version: 1.4.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-daytona
  dependency-version: 0.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-fireworks
  dependency-version: 1.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-google-genai
  dependency-version: 4.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-mcp-adapters
  dependency-version: 0.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: langchain-modal
  dependency-version: 0.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langchain-openai
  dependency-version: 1.3.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: langchain-runloop
  dependency-version: 0.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langgraph
  dependency-version: 1.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: langsmith
  dependency-version: 0.9.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: uvicorn
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 20:05:08 +00:00
Adam Moussa
a4ed19ba61
feat: migrate model providers to Bedrock (Claude) + Fireworks (everything else) (#62)
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled
* feat: switch model providers to AWS Bedrock (Claude) and Fireworks (non-Claude)

Migrate off direct provider APIs: AWS Bedrock for Anthropic/Claude via the
cross-region inference profile us.anthropic.claude-opus-4-8, Fireworks AI for
all non-Claude models. Drop OpenAI (gpt-5.5) and Google (gemini-3.5-flash)
entirely. DEFAULT_MODEL_ID is now Bedrock Claude; all Fireworks models stay
freely selectable for the agent and reviewer graphs and via team/profile
defaults.

- pyproject: add langchain-aws (ChatBedrockConverse + boto3)
- options.py: Bedrock Claude entry + default; remove openai/google entries
- model.py: bedrock_converse provider_model_kwargs (effort -> thinking budget),
  region pin in make_model, bedrock<->fireworks fallback pairing, AWS_REGION/
  FIREWORKS_API_KEY local-dev validation
- server.py: provider-aware fallback kwargs build
- sanitize_thinking_blocks: also sanitize ChatBedrockConverse thinking blocks
- model_fallback: treat transient botocore ClientError codes as fallback-worthy
- eval_jobs: repoint hardcoded eval model id to Bedrock Claude
- tests: repoint dropped model ids; drop obsolete google test module

* fix(bedrock): use adaptive thinking + output_config.effort for Opus 4.8

The handoff spec wired Bedrock Converse thinking as
{type: enabled, budget_tokens: N}, but Opus 4.7+ rejects that with a
ValidationException: thinking.type "enabled" is not supported; it requires
thinking.type "adaptive" plus output_config.effort. Verified by live invoke
against us.anthropic.claude-opus-4-8 (account 328440206208, us-east-1):
the enabled+budget shape 400s, adaptive+effort returns normally.

Map profile effort to additional_model_request_fields:
  {thinking: {type: adaptive, display: summarized},
   output_config: {effort: <low|medium|high|xhigh|max>}}
reusing anthropic_thinking_for/anthropic_effort_for. Update the two
subagent-model tests asserting the old shape.

* fix(deploy): seed Bedrock/Fireworks models, not the dropped anthropic:/openai: ids

Model selection is store-driven, so seed_store.sh's team_settings/default seed is
what runs in prod. It still seeded the removed providers, which would fail at runtime
after the migration:
- agent/builder: anthropic:claude-opus-4-8 -> bedrock_converse:us.anthropic.claude-opus-4-8
- reviewer: openai:gpt-5.5 (dropped) -> bedrock_converse:us.anthropic.claude-opus-4-8
  (set SEED_REVIEWER_MODEL to a Fireworks model for a cross-family reviewer)
- fetch-config REQUIRED_PROVIDER_KEYS default ANTHROPIC_API_KEY,OPENAI_API_KEY ->
  FIREWORKS_API_KEY (Bedrock auths via host IAM role; dropping the old keys would
  otherwise fail-fast at boot)
- docs (DEPLOYMENT/ROTATION/put-config) updated to match.

Surfaced by the cross-family review + verified against deploy/.

* fix(bedrock): security-review NITs — region resolution, error sanitization, reasoning-block strip

From /sh-security-review (all confirmed-low):
- model.py: resolve region from AWS_REGION OR AWS_DEFAULT_REGION (matches
  validate_local_dev_llm_config) so the validated region is the one actually used.
- model_fallback.py: sanitize Bedrock AccessDenied/ResourceNotFound errors to the
  error code only, so the role ARN + account id in the raw botocore message never
  reach logs or the user channel (CWE-209).
- sanitize_thinking_blocks.py: also strip empty Bedrock reasoning_content blocks
  (Converse emits reasoning_content, not thinking) so the middleware is not a no-op
  on Bedrock; + unit tests. (Empty blocks replay fine today; defensive.)

* deploy(bedrock): grant instance-role Bedrock invoke + repoint LLM_MODEL_ID / eval model ids

Deployment-readiness for the Bedrock migration (PR #62):
- instance-role.ts: least-privilege bedrock:InvokeModel[WithResponseStream] on the
  us.anthropic.claude-opus-4-8 inference-profile ARN + the foundation-model ARN in
  each routed region (us-east-1/2, us-west-2). The model runs in the server process
  on the box, so the EC2 instance role is the principal. Simulator-verified (allowed
  for opus-4-8, implicitDeny for other models) and synth-verified. Passed the
  mandatory GPT-4.1 IAM cross-review (no blockers, least-privilege confirmed).
- config-store.ts: IaC SSM LLM_MODEL_ID anthropic:claude-opus-4-8 ->
  bedrock_converse:us.anthropic.claude-opus-4-8. This SSM value overrides
  seed_store.sh's default via pick precedence, so the seed-script fix alone was
  insufficient — both sources now point at the supported Bedrock id.
- infra/README.md + evals/reviewer/config.toml: repoint stale anthropic:/google_genai:
  ids to the Bedrock id (config.toml's model_id was an active, now-broken value).

AWS_REGION is already wired via user-data.sh (IMDS -> boot.env), so no change needed there.

* chore(secrets): drop OPENAI/GOOGLE/GROQ key shells (revoked, providers removed)

Those three providers were dropped in the Bedrock/Fireworks migration and their keys
revoked; the live Secrets Manager objects (open-swe-{dev,prod}/{OPENAI,GOOGLE,GROQ}_API_KEY)
were deleted (7-day recovery). Remove them from the IaC so a future cdk deploy does not
recreate the shells, and from fetch-config's mirror array so boot stops requesting them:
- config-store.ts SECRET_VARS + descriptions (28 -> 25 shells)
- fetch-config.sh SECRET_VARS array (kept in lockstep)
- put-config.sh: drop the put_secret lines; ANTHROPIC_API_KEY re-labelled optional
  (eval judge only — Bedrock builder/reviewer auth via the host IAM role).

REQUIRED_PROVIDER_KEYS is not set in SSM, so it uses the FIREWORKS_API_KEY default.
2026-06-29 15:57:19 -04:00
Adam Moussa
8a9974c3c4
feat: author Slack/dashboard/schedule commits + PRs as the app by default (#57) (#60)
Some checks failed
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Build & publish app artifacts / Publish + deploy (dev) (push) Has been cancelled
Build & publish app artifacts / Publish + deploy (prod) (push) Has been cancelled
Infra CD / Infra CI (pre-deploy) (push) Has been cancelled
Infra CD / Deploy open-swe-dev (push) Has been cancelled
Infra CD / Deploy open-swe-prod (push) Has been cancelled
* feat: default Slack/dashboard/schedule PRs + commits to the app identity (#57)

Slack/dashboard/schedule runs now author PRs and run git/gh operations as the
GitHub App seahaven-openswe[bot] by default (matching GitHub-issue runs), so the
self-review 422 is impossible by construction rather than guarded in the prompt.
A profile flag author_prs_as_user restores per-user attribution.

- open_pull_request._resolve_pr_author_token + auth.resolve_github_token: default
  to the installation token for these sources; per-user only when opted in.
- authorship: commit identity -> seahaven-openswe[bot] (numeric noreply;
  accepted Vercel-resolution risk, documented inline).
- self-trigger safety: INTERNAL_BOT_LOGINS + webapp/reviewer_reconcile/reply
  markers recognize seahaven-openswe[bot] (bot-authored events are now ours).

Supersedes the prompt-only guard in #58.

* fix: author commits as the app bot in the default path (SH-IDSPLIT-01)

Security review found the commit identity was NOT actually unified to the bot:
resolve_triggering_user_identity got a 403 from the installation token and fell
back to configurable['github_login'], so commits were still authored as the
triggering user (commit=user, push+PR=bot — a three-way split that missed the
stated goal). Now gate the triggering-user identity resolution on the same
default-bot decision as the token: slack/dashboard/schedule default to the app
bot identity unless author_prs_as_user is set.

* docs(security): record AUTHZ-SLACK-BOT-DEFAULT-001 as an accepted residual (#59)

Single-user deployment; bounded by App-on-pilot + ALLOWED_GITHUB_REPOS lock.
Revisit (add a per-user gate) before expanding users or the App installation.
2026-06-29 14:22:33 -04:00
Adam Moussa
134963647b
chore(security): suppress pre-existing history scanner false-positives (#56)
Some checks are pending
Build & publish app artifacts / Publish + deploy (dev) (push) Waiting to run
Build & publish app artifacts / Publish + deploy (prod) (push) Waiting to run
Infra CD / Infra CI (pre-deploy) (push) Waiting to run
Infra CD / Deploy open-swe-dev (push) Blocked by required conditions
Infra CD / Deploy open-swe-prod (push) Blocked by required conditions
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Adds repo-local suppressions for 5 verified-FP gitleaks findings that block
pushes (forcing --no-verify), all in committed history / docs / CI fixtures:
- .env.ci (dev-only e2e values, intentionally committed)
- .env.example (placeholders)
- .github/ci/fake_github_app_key.pem (throwaway CI test key)
- INSTALLATION.md (example GITHUB_APP_PRIVATE_KEY .env block)
- README.md (prose mis-matched by the generic-api-key heuristic)
Repo-local (not machine-level) so they load in git worktrees too. Also drops
the now-obsolete OSWE-IAC-AUDIT-01 suppression (B-1, fixed in #55).
2026-06-29 12:54:40 -04:00
Adam Moussa
e9499e49b8
fix: isolate dev CDK deploys on their own bootstrap qualifier (B-1/OSWE-IAC-01) (#55)
* fix: isolate dev CDK deploys on their own bootstrap qualifier (B-1)

Dev synthesizes against the oswedev qualifier and the dev infra deploy role
is scoped to cdk-oswedev-* — it can no longer assume the default hnb659fds
bootstrap roles whose admin cfn-exec-role deploys prod, closing the cross-env
escalation (OSWE-IAC-01). Prod stays on the default qualifier.

* test: assert per-env bootstrap qualifier isolation + document (B-1)
2026-06-29 12:39:23 -04:00
Adam Moussa
a33aaec495
fix: resolve security-review findings (sandbox isolation, IAM list scope, webhook replay, info-leak) (#54)
* fix: enforce a replay window on Linear webhooks (AUTHZ-001)

verify_linear_signature accepted any correctly-signed body with no freshness
check, so a captured request could be replayed indefinitely. Parse the
signed webhookTimestamp (Unix ms) and reject requests outside a 60s window,
failing closed when the field is missing or malformed — mirroring the Slack
verifier.

* fix: stop leaking upstream auth-error bodies into user comments

get_github_token_for_user folded the raw upstream response text into the
error string that becomes a Slack/Linear comment (AUTH-RESP-LEAK-01). Log the
full body server-side only and return a generic "GitHub auth failed (status
<code>)". Also document the accepted shared-installation-token blast radius on
the bot-token-only path (AUTHZ-003).

* fix: bind sandbox and token caches to repo to prevent thread-id collision

A PR head-branch name is attacker-controllable and get_thread_id_from_branch
derives a thread_id from its first UUID with no repo binding (TID-COLLIDE-01).
The in-memory sandbox cache and the per-thread GitHub-token cache were keyed on
thread_id alone, and a cached sandbox was reused after only an echo-ping, so a
different repo's webhook could bind to another thread's sandbox or token.

Without changing the persistent thread-id scheme:
- Persist the bound repo (owner/name) in thread metadata on sandbox creation and
  refuse to reuse a sandbox whose bound repo does not match the current event
  (SandboxRepoMismatchError); the in-memory proxy also carries the binding.
- Bind the GitHub-token cache entries to their repo and evict on a cross-repo
  read so a colliding thread_id cannot be served another repo's token.
- Thread repo through the reviewer and the webhook token resolvers.

* fix: scope s3:ListBucket to the releases/ prefix (F-1/IAC-04)

The instance role and the GitHub deploy app role granted s3:ListBucket on the
whole assets bucket. Every caller (deploy.sh, the publish/rollback scripts)
only ever lists under releases/, so add a StringLike s3:prefix=releases/*
condition. GetBucketLocation has no s3:prefix in its request context, so it
moves to its own unconditioned statement. Also document the accepted F-2
cross-env existence-oracle residual on BatchGetSecretValue.

* chore: suppress test-fixture credential false positive; document AUTHZ-002

Add a machine-level suppression for the fake Datadog key in the
test_team_credentials encryption-roundtrip fixture (CWE-798, not a real
credential). Clarify that the within-org thread-write path is intentional by
design (AUTHZ-002) — comment only, no behavior change.

* fix: casefold repo-binding keys to avoid spurious cross-repo mismatch

GitHub owner/name are case-insensitive. Casefold the owner/name key on both the
write (binding) and read (compare) sides — repo_cache_key and the metadata
bound_repo read — so Org/Repo and org/repo resolve to one repo and a legitimate
same-repo run cannot raise a spurious SandboxRepoMismatchError (Gap 2).

* fix: stop leaking upstream auth body in unexpected-result branch

The 2xx-but-missing-token/url branch echoed the parsed upstream response body
into the user-facing error. Return a generic message and log response_data
server-side only, mirroring the existing HTTPStatusError fix (Gap 4).

* fix: fail closed for unbound-legacy sandboxes and catch repo mismatch

Gap 1: a thread with a persisted sandbox_id but no in-memory cache and no
recorded bound_repo (a pre-binding legacy thread, post-deploy) previously
reconnected-and-served the sandbox to the current repo, then rebound it. Now
fail closed: drop the stale id and recreate a fresh sandbox bound to this repo,
logging a reconnect-with-missing-binding event. A sandbox is never served to a
repo unless its binding is known and matches; new threads bind on first run
unchanged.

Gap 3: catch SandboxRepoMismatchError at the agent and reviewer run entrypoints,
log it for alarming, and surface a clean sanitized error instead of letting an
opaque deep-stack exception crash-loop the worker.

* chore: suppress test-fixture credential false positive in token-TTL tests

Add a machine-level suppression for the fake "ghp_secret" GitHub token used by
the cached-token TTL/revocation unit tests (CWE-798). Not a real credential and
not a valid PAT; scoped to the unit test only.
2026-06-29 12:21:19 -04:00
Adam Moussa
9444fd7677
docs: document live AWS prod deploy for Open SWE (#53)
Some checks are pending
Build & publish app artifacts / Publish + deploy (dev) (push) Waiting to run
Build & publish app artifacts / Publish + deploy (prod) (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
Prod went live 2026-06-29 on self-hosted AWS EC2 behind the shared
seahaven-com ALB, superseding the on-prem VM model the runbook described.

- Rewrite deploy/seahaven/DEPLOYMENT.md as the canonical end-to-end runbook:
  infra CD (CDK stacks + OIDC roles + prod approval gate), config seeding
  (put-config.sh, the 13 boot-required prod vars, fetch-config fail-fast),
  app artifact deploy (S3 + SSM roll + is-active gate), promotion/rollback,
  live prod facts, and a RETAIN secret-shell troubleshooting entry that
  cross-references infra/README.md.
- Correct retired *.seahavenind.com hosts to *.seahaven.com throughout and
  document the live GitHub/Slack/Linear webhook + OAuth endpoints.
- Add a concise Deployment section to README pointing at the runbook.
- Fix the stale host in the retired on-prem nginx/openswe.conf and mark it
  superseded by the AMI template.
2026-06-29 11:12:44 -04:00
Adam Moussa
f379fbdaa9
docs: document RETAIN secret-shell orphan gotcha (#52)
Some checks are pending
Infra CD / Infra CI (pre-deploy) (push) Waiting to run
Infra CD / Deploy open-swe-dev (push) Blocked by required conditions
Infra CD / Deploy open-swe-prod (push) Blocked by required conditions
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
RETAIN + a fixed secret name means a failed FIRST create leaves empty
secret shells behind when the stack rolls back. The shells keep the
global `open-swe-<env>/<VAR>` names, so every later create fails with
`AlreadyExists`, and a plain delete-secret keeps the name reserved for
the recovery window rather than freeing it.

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

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

1
.gitignore vendored
View file

@ -72,3 +72,4 @@ __pycache__/
TODO.md
# infra/cdk-outputs.json
.idea/

View file

@ -1,24 +1,128 @@
{
"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",
"id": "AUTHZ-SLACK-BOT-DEFAULT-001",
"title": "Slack entrypoint lacks a per-user repo-access check; default-bot PR authoring removes the implicit per-user repo boundary",
"file": "agent/webapp.py",
"severity": "medium",
"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.",
"suppression_justification": "ACCEPTED (Adam, 2026-06-29) while Open SWE has a SINGLE user. The Slack run entrypoint does not call require_repo_access_for_user (dashboard/schedule do), so with the default App installation token a mapped Slack user could act on any repo in the App's installation regardless of their own access. Bounded by the compensating controls: the seahaven-openswe App is installed on open-swe-pilot ONLY and ALLOWED_GITHUB_REPOS is locked, so the bot token cannot reach repos outside the pilot, and the only triggering user is the owner. Tracked as open issue #59 with three remediation options. REVISIT TRIGGER: before expanding the user base OR broadening the App's installation beyond open-swe-pilot — at that point this becomes HIGH and a gate (option (c): per-user check when a token exists) must be added. Verified medium (not high) by /sh-security-review proof-or-kill verifier.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "OSWE-IAC-SECRETS-LIST-01",
"title": "EC2 instance role grants BatchGetSecretValue on \"*\" (operation-level; secret-NAME existence enumeration account-wide)",
"file": "infra/lib/constructs/instance-role.ts",
"severity": "low",
"status": "confirmed",
"suppression_justification": "ACCEPTED LOW residual, metadata-only. secretsmanager:BatchGetSecretValue is a collection action that AWS cannot scope to a per-secret ARN, so it is granted on `*` (documented in commit 3c69dd9d and the construct comment). Secret VALUES remain strictly gated by the PREFIX-scoped GetSecretValue/DescribeSecret on secret:open-swe-<env>/* (checked per-secret even within the batch), so cross-env VALUE isolation is preserved; only a name EXISTENCE oracle remains, within Sea Haven's single-tenant account 328440206208. ListSecrets is intentionally NOT granted (so name FILTER enumeration AccessDenies). Confirmed by GPT-4.1 IAM cross-review (no BLOCK) and the iac-iam detector (one low residual, no critical/high).",
"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",
"id": "gitleaks-generic-api-key-89",
"title": "Hardcoded credential flagged in encryption-roundtrip test fixture (CWE-798)",
"file": "tests/test_team_credentials.py",
"line": 89,
"rule": "CWE-798",
"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.",
"status": "false-positive",
"justification": "Test fixture, not a real credential. The value \"secret-api-1234\" is a fake Datadog API key used by test_datadog_roundtrip_and_redaction to assert that the plaintext key is recoverable after an encrypt/decrypt round-trip (and that the stored record holds ciphertext, not the plaintext). It is never a live secret and is scoped to the unit test only.",
"suppression_justification": "Test fixture, not a real credential. The value \"secret-api-1234\" is a fake Datadog API key used by test_datadog_roundtrip_and_redaction to assert that the plaintext key is recoverable after an encrypt/decrypt round-trip. It is never a live secret and is scoped to the unit test only.",
"owner": "adam@seahavenind.com",
"added": "2026-06-26"
"added": "2026-06-29"
},
{
"id": "gitleaks-generic-api-key-79",
"title": "Hardcoded credential flagged in encryption-roundtrip test fixture (CWE-798)",
"file": "tests/test_team_credentials.py",
"line": 79,
"rule": "CWE-798",
"severity": "low",
"status": "false-positive",
"justification": "Test fixture, not a real credential. Same fake Datadog API key \"secret-api-1234\" passed into connect_datadog by test_datadog_roundtrip_and_redaction. Never a live secret; scoped to the unit test only.",
"suppression_justification": "Test fixture, not a real credential. Same fake Datadog API key \"secret-api-1234\" passed into connect_datadog by test_datadog_roundtrip_and_redaction. Never a live secret; scoped to the unit test only.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-generic-api-key-23",
"title": "Hardcoded credential flagged in GitHub-token TTL test fixture (CWE-798)",
"file": "tests/test_github_token_ttl.py",
"line": 23,
"rule": "CWE-798",
"severity": "high",
"status": "false-positive",
"justification": "Test fixture, not a real credential. The literal \"ghp_secret\" is a fake GitHub token used by the cached-token TTL/revocation unit tests. Not a valid 40-char GitHub PAT, never a live secret, scoped to the unit test only.",
"suppression_justification": "Test fixture, not a real credential. The literal \"ghp_secret\" is a fake GitHub token used by the cached-token TTL/revocation unit tests. Not a valid 40-char GitHub PAT, never a live secret, scoped to the unit test only.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-generic-api-key-24",
"title": "Dev-only CI env value flagged in .env.ci (history-only; file not at HEAD)",
"file": ".env.ci",
"line": 24,
"rule": "gitleaks-generic-api-key",
"severity": "high",
"status": "false-positive",
"justification": "False positive. .env.ci is the e2e CI env file (added in commit 5a52b9b2 'ci: run playwright e2e tests') holding DELIBERATELY-FAKE, dev-only values explicitly marked 'committed intentionally' (e.g. GITHUB_WEBHOOK_SECRET=dev-secret and a dev-only Fernet TOKEN_ENCRYPTION_KEY used solely by the Playwright e2e suite). No production secret: real prod values live in Secrets Manager (open-swe-prod/*). gitleaks scans committed history so it flags this even though the file is not present at HEAD.",
"suppression_justification": "Dev-only CI fixture value, intentionally committed for the e2e suite; not a production secret (prod secrets are in Secrets Manager). Flagged from git history; file not present at HEAD.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-generic-api-key-3",
"title": "Placeholder flagged in .env.example (history-only; file not at HEAD)",
"file": ".env.example",
"line": 3,
"rule": "gitleaks-generic-api-key",
"severity": "high",
"status": "false-positive",
"justification": "False positive. .env.example contains placeholder/example values only, by definition not real secrets. gitleaks scans committed history so it flags the placeholder even though the file is not present at HEAD.",
"suppression_justification": "Example/placeholder value in a committed .env.example; flagged from git history. Not a real secret.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-private-key-1",
"title": "Fake CI RSA key flagged in .github/ci/fake_github_app_key.pem (history-only)",
"file": ".github/ci/fake_github_app_key.pem",
"line": 1,
"rule": "gitleaks-private-key",
"severity": "high",
"status": "false-positive",
"justification": "False positive. This is a throwaway test RSA key (the filename is literally 'fake_github_app_key.pem') referenced by .env.ci for the Playwright e2e suite. It is not a production GitHub App key (the real prod key is in Secrets Manager open-swe-prod/GITHUB_APP_PRIVATE_KEY). gitleaks scans committed history; the file is not present at HEAD.",
"suppression_justification": "Deliberately-fake CI test key for the e2e suite; not a production GitHub App key. Flagged from git history; file not present at HEAD.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-private-key-185",
"title": "Documentation example PEM flagged in INSTALLATION.md (CWE-798)",
"file": "INSTALLATION.md",
"line": 185,
"rule": "gitleaks-private-key",
"severity": "high",
"status": "false-positive",
"justification": "False positive. INSTALLATION.md shows the .env format with an example GITHUB_APP_PRIVATE_KEY=\"-----BEGIN RSA PRIVATE KEY-----...\" block in the setup instructions. It is illustrative documentation, not a real key.",
"suppression_justification": "Documentation example of the GITHUB_APP_PRIVATE_KEY .env format in INSTALLATION.md; not a real key.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
},
{
"id": "gitleaks-generic-api-key-29",
"title": "README prose flagged as a generic API key (CWE-798)",
"file": "README.md",
"line": 29,
"rule": "gitleaks-generic-api-key",
"severity": "high",
"status": "false-positive",
"justification": "False positive. README.md line 29 is descriptive project prose (the 'Open SWE is the open-source version...' paragraph / blog link); gitleaks' generic-api-key entropy heuristic mis-matched a token in the text. No secret is present.",
"suppression_justification": "README descriptive prose mis-matched by the generic-api-key entropy heuristic; no secret present.",
"owner": "adam@seahavenind.com",
"added": "2026-06-29"
}
]
}

View file

@ -151,6 +151,21 @@ This is an area where you can extend Open SWE for your org: add deterministic CI
- **[Installation Guide](INSTALLATION.md)** — local dev (backend + dashboard), GitHub App creation, LangSmith, Linear/Slack/GitHub triggers, and production deployment
- **[Customization Guide](CUSTOMIZATION.md)** — swap the sandbox, model, tools, triggers, system prompt, and middleware for your org
## Deployment (Sea Haven fork)
This fork is **self-hosted on AWS** and live in production. Each env (`dev` /
`prod`) runs the stock `langgraph dev` server (all three graphs + the FastAPI
webapp) bound to loopback `127.0.0.1:2024` on a single ARM64 EC2 box, fronted by
nginx (the sole ingress) behind the shared `seahaven-com` ALB. CDK (`infra/`)
owns the per-env stacks; GitHub Actions handle CDK deploys (`cd-infra.yml`) and
app-artifact releases to S3 rolled onto the box via an SSM document
(`build-artifacts.yml`), with `dev` auto-deploying and `prod` gated behind a
manual GitHub Environment approval.
**[`deploy/seahaven/DEPLOYMENT.md`](deploy/seahaven/DEPLOYMENT.md) is the canonical
deploy runbook** — full end-to-end pipeline, config seeding, promotion/rollback,
and live prod facts. CDK specifics live in [`infra/README.md`](infra/README.md).
## License
MIT

View file

@ -92,6 +92,23 @@ def profile_create_prs(profile: dict[str, Any] | None) -> bool:
return False
def profile_author_prs_as_user(profile: dict[str, Any] | None) -> bool:
"""Return whether Slack/dashboard/schedule PRs should be authored as the
triggering user (their per-user OAuth token) instead of the App bot.
Defaults to False: by default these PRs (and the run's git/gh operations) are
authored as the GitHub App `seahaven-openswe[bot]`, matching GitHub-issue
runs and making the self-review 422 impossible. Set the profile flag
``author_prs_as_user: true`` to opt back into per-user attribution.
"""
if not isinstance(profile, dict):
return False
value = profile.get("author_prs_as_user")
if isinstance(value, bool):
return value
return False
def _normalize_profile_model_pair(
profile: dict[str, Any],
*,

View file

@ -52,7 +52,7 @@ DEFAULT_REVIEWER_EVAL_CONFIG: ReviewerEvalConfig = {
"langsmith_project": DEFAULT_EVAL_PROJECT,
"langgraph_url": "",
"assistant_id": "reviewer",
"model_id": "google_genai:gemini-3.5-flash",
"model_id": "bedrock_converse:us.anthropic.claude-opus-4-8",
"reasoning_effort": "medium",
"score_mode": "all_findings",
"severity_threshold": "medium",

View file

@ -15,26 +15,12 @@ class ModelOption(TypedDict):
SUPPORTED_MODELS: list[ModelOption] = [
{
"id": "anthropic:claude-opus-4-8",
"label": "Opus 4.8",
"id": "bedrock_converse:us.anthropic.claude-opus-4-8",
"label": "Opus 4.8 (Bedrock)",
"efforts": ["low", "medium", "high", "xhigh", "max"],
"default_effort": "high",
"supports_images": True,
},
{
"id": "openai:gpt-5.5",
"label": "GPT-5.5",
"efforts": ["none", "low", "medium", "high", "xhigh"],
"default_effort": "xhigh",
"supports_images": True,
},
{
"id": "google_genai:gemini-3.5-flash",
"label": "Gemini 3.5 Flash",
"efforts": ["minimal", "low", "medium", "high"],
"default_effort": "medium",
"supports_images": True,
},
{
"id": "fireworks:accounts/fireworks/models/kimi-k2p7-code",
"label": "Kimi K2.7",
@ -60,7 +46,7 @@ SUPPORTED_MODELS: list[ModelOption] = [
SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS)
DEFAULT_MODEL_ID: str = "openai:gpt-5.5"
DEFAULT_MODEL_ID: str = "bedrock_converse:us.anthropic.claude-opus-4-8"
DEFAULT_MODEL_EFFORT: str = "medium"

View file

@ -1206,6 +1206,8 @@ async def send_dashboard_message(
raise HTTPException(404, "thread not found") from exc
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
# AUTHZ-002 (intentional): any org-gated member who can read a surfaced thread
# may also post into it; non-owners are attributed via _attribution_prefix.
_assert_thread_readable(metadata)
prompt = f"{_attribution_prefix(metadata, login, email)}{body.content.strip()}"

View file

@ -18,6 +18,7 @@ from typing import Any
import anthropic
import openai
from botocore.exceptions import ClientError
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_core.language_models import BaseChatModel
@ -39,6 +40,14 @@ _TRANSIENT_EXCEPTIONS: tuple[type[BaseException], ...] = (
)
_RETRYABLE_BEDROCK_ERROR_CODES = {
"ThrottlingException",
"ServiceUnavailableException",
"ModelTimeoutException",
"InternalServerException",
}
def _should_fallback(exc: BaseException) -> bool:
if isinstance(exc, _TRANSIENT_EXCEPTIONS):
return True
@ -47,6 +56,11 @@ def _should_fallback(exc: BaseException) -> bool:
status = getattr(exc, "status_code", None)
if isinstance(status, int) and status in _RETRYABLE_STATUS_CODES:
return True
# Bedrock (Claude) raises botocore ClientError for transient throttling/5xx.
if isinstance(exc, ClientError):
code = exc.response.get("Error", {}).get("Code", "")
if code in _RETRYABLE_BEDROCK_ERROR_CODES:
return True
return False
@ -87,6 +101,18 @@ def _provider_access_error_message(exc: BaseException) -> str | None:
"Choose a different model or update the workspace's OpenAI access and retry."
)
# Bedrock access/lookup failures embed the caller's role ARN and account id in the
# raw botocore message; surface only the error code so identifiers never reach logs
# or the user-facing channel.
if isinstance(exc, ClientError):
code = exc.response.get("Error", {}).get("Code", "")
if code in {"AccessDeniedException", "ResourceNotFoundException"}:
return (
"The selected Bedrock model is not available to this deployment "
f"(Bedrock error: {code}). Verify the model's inference-profile access and "
"IAM permissions, choose a different model, and retry."
)
return None

View file

@ -8,6 +8,7 @@ from typing import Any
from langchain.agents.middleware import AgentMiddleware
from langchain.agents.middleware.types import ModelCallResult, ModelRequest, ModelResponse
from langchain_anthropic import ChatAnthropic
from langchain_aws import ChatBedrockConverse
from langchain_core.messages import AIMessage
@ -15,7 +16,7 @@ def _is_chat_anthropic(model: object) -> bool:
seen: set[int] = set()
current = model
for _ in range(10):
if isinstance(current, ChatAnthropic):
if isinstance(current, (ChatAnthropic, ChatBedrockConverse)):
return True
current_id = id(current)
if current_id in seen:
@ -28,19 +29,32 @@ def _is_chat_anthropic(model: object) -> bool:
return False
def _is_empty_thinking_block(block: object) -> bool:
"""True for an empty Anthropic ``thinking`` block or an empty Bedrock
``reasoning_content`` block (Bedrock Converse emits the latter shape)."""
if not isinstance(block, dict):
return False
block_type = block.get("type")
if block_type == "thinking":
return not block.get("thinking")
if block_type == "reasoning_content":
payload = block.get("reasoning_content")
if isinstance(payload, dict):
return not (
payload.get("text")
or payload.get("signature")
or payload.get("redactedContent")
or payload.get("redacted_content")
)
return not payload
return False
def _sanitize_messages(messages: list[Any]) -> None:
for message in messages:
if not isinstance(message, AIMessage) or not isinstance(message.content, list):
continue
content = [
block
for block in message.content
if not (
isinstance(block, dict)
and block.get("type") == "thinking"
and not block.get("thinking")
)
]
content = [block for block in message.content if not _is_empty_thinking_block(block)]
if len(content) != len(message.content):
message.content = content

View file

@ -391,7 +391,7 @@ When you have completed your implementation, follow these steps in order:
2. **Review your changes**: Review the diff to ensure correctness. Verify no regressions or unintended modifications.
3. **Submit**: Commit locally, push with `git push origin <branch>`, then open or update the PR when a PR is requested, necessary, or required by the Always Create PRs dashboard setting.
- **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.
- **Open a new PR** with the `open_pull_request` tool (pass `owner`, `repo`, `head` = your branch, `base`, `title`, `body`). By default the PR is authored by the app (`seahaven-openswe[bot]`), like GitHub-issue-triggered runs (a user can opt back into per-user attribution via the `author_prs_as_user` profile setting). 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): 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.

View file

@ -59,6 +59,7 @@ from .server import (
DEFAULT_LLM_MAX_TOKENS,
DEFAULT_RECURSION_LIMIT,
MODEL_CALL_RECURSION_LIMIT,
SandboxRepoMismatchError,
_general_purpose_subagent,
ensure_sandbox_for_thread,
graph_loaded_for_execution,
@ -839,7 +840,9 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
f"GitHub App installation token unavailable for reviewer thread {thread_id}"
)
# Cache in-process so reviewer tools and the sandbox proxy can read it this run.
cache_github_token_for_thread(thread_id, github_token, expires_at=expires_at)
cache_github_token_for_thread(
thread_id, github_token, expires_at=expires_at, repo=repo_config
)
github_proxy_token = github_token
github_api_token = github_token
@ -849,12 +852,18 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
if repo_config.get("owner") and repo_config.get("name")
else None
)
sandbox_backend = await ensure_sandbox_for_thread(
thread_id,
github_proxy_token=github_proxy_token,
github_proxy_repositories=[repo_name_for_scope] if repo_name_for_scope else None,
repo=repo_for_snapshot,
)
try:
sandbox_backend = await ensure_sandbox_for_thread(
thread_id,
github_proxy_token=github_proxy_token,
github_proxy_repositories=[repo_name_for_scope] if repo_name_for_scope else None,
repo=repo_for_snapshot,
)
except SandboxRepoMismatchError as exc:
# Repo-binding refusal at the run boundary: log for alarming and surface the
# sanitized terminal error rather than crash-looping the reviewer worker.
logger.error("Refusing reviewer run for thread %s: %s", thread_id, exc)
raise RuntimeError(str(exc)) from exc
work_dir = await aresolve_sandbox_work_dir(sandbox_backend)

View file

@ -16,7 +16,7 @@ ReviewThreadMatch = tuple[ReviewThread, int | None]
def _is_open_swe_bot_comment(comment: ReviewThread) -> bool:
return comment.get("author") in {"open-swe", "open-swe[bot]"}
return comment.get("author") in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}
def _int_list(value: Any) -> list[int]:
@ -52,7 +52,7 @@ def _human_replies_after_bot_comment(
if not seen_bot_comment:
continue
author = comment.get("author")
if author in {"open-swe", "open-swe[bot]"}:
if author in {"open-swe", "open-swe[bot]", "seahaven-openswe[bot]"}:
continue
replies.append(comment)
return replies

View file

@ -36,6 +36,7 @@ from .dashboard.agent_overrides import (
load_profile,
normalize_profile_overrides,
normalize_profile_subagent_overrides,
profile_author_prs_as_user,
profile_create_prs,
resolve_github_login,
)
@ -98,9 +99,8 @@ from .utils.github_app import (
get_github_app_installation_token_with_expiry,
)
from .utils.github_proxy import record_proxy_token_expiry
from .utils.github_token import repo_cache_key
from .utils.model import (
DEFAULT_LLM_REASONING,
ModelKwargs,
fallback_model_id_for,
make_model,
provider_model_kwargs,
@ -117,6 +117,7 @@ SANDBOX_POLL_INTERVAL = 1.0
from .utils.sandbox_state import (
SANDBOX_BACKENDS,
get_bound_repo_from_metadata,
get_sandbox_id_from_metadata,
set_sandbox_backend,
unwrap_sandbox_backend,
@ -405,6 +406,24 @@ def graph_loaded_for_execution(config: RunnableConfig) -> bool:
)
class SandboxRepoMismatchError(RuntimeError):
"""Raised when a thread_id is presented for a repo it is not bound to.
A thread is bound to exactly one repo. A different repo presenting a
colliding thread_id (e.g. an attacker-named branch whose first UUID matches
another thread) must never reuse this thread's sandbox or token.
"""
def __init__(self, thread_id: str, bound_repo: str, current_repo: str) -> None:
self.thread_id = thread_id
self.bound_repo = bound_repo
self.current_repo = current_repo
super().__init__(
f"Thread {thread_id} is bound to repo {bound_repo}, "
f"refusing to serve sandbox for {current_repo}"
)
async def ensure_sandbox_for_thread(
thread_id: str,
*,
@ -435,6 +454,43 @@ async def ensure_sandbox_for_thread(
logger.info("Sandbox creation in progress for thread %s, waiting...", thread_id)
sandbox_id = await _resolve_creating_sentinel(thread_id)
# Repo-binding guard (TID-COLLIDE-01): a sandbox is never served to a repo
# unless its binding is known and matches.
current_repo = repo_cache_key(repo)
bound_repo = await get_bound_repo_from_metadata(thread_id)
proxy_bound = getattr(sandbox_backend, "bound_repo", None)
effective_bound = bound_repo or (proxy_bound if isinstance(proxy_bound, str) else None)
if current_repo and effective_bound and effective_bound != current_repo:
# Known binding that does not match the current repo: refuse outright so a
# colliding thread_id from a different repo cannot reuse/clobber it.
logger.error(
"Repo mismatch for thread %s: bound=%s current=%s; refusing sandbox reuse",
thread_id,
effective_bound,
current_repo,
)
raise SandboxRepoMismatchError(thread_id, effective_bound, current_repo)
if (
current_repo
and not effective_bound
and sandbox_backend is None
and isinstance(sandbox_id, str)
and sandbox_id not in (None, SANDBOX_CREATING)
):
# Fail CLOSED for unbound-legacy threads (migration window): a thread with a
# persisted sandbox_id but no in-memory cache and no recorded bound_repo
# cannot be confirmed to belong to the current repo, so never
# reconnect-and-serve it. Drop the stale id and recreate a fresh sandbox
# bound to this repo below.
logger.error(
"reconnect-with-missing-binding for thread %s: persisted sandbox %s has no "
"bound_repo; refusing reuse and recreating for repo %s",
thread_id,
sandbox_id,
current_repo,
)
sandbox_id = None
if sandbox_backend:
logger.info("Using cached sandbox backend for thread %s", thread_id)
original_sandbox_id = sandbox_backend.id
@ -493,12 +549,15 @@ async def ensure_sandbox_for_thread(
sandbox_backend, thread_id, github_proxy_token, github_proxy_repositories, repo
)
sandbox_backend = set_sandbox_backend(thread_id, sandbox_backend)
sandbox_backend = set_sandbox_backend(thread_id, sandbox_backend, repo=current_repo)
metadata_update: dict[str, Any] = {}
if sandbox_id != sandbox_backend.id:
await client.threads.update(
thread_id=thread_id, metadata={"sandbox_id": sandbox_backend.id}
)
metadata_update["sandbox_id"] = sandbox_backend.id
if current_repo and bound_repo != current_repo:
metadata_update["bound_repo"] = current_repo
if metadata_update:
await client.threads.update(thread_id=thread_id, metadata=metadata_update)
# Re-apply git identity every run: cached/reconnected sandboxes may have
# lost their `--global` config (or had it overwritten), and Vercel preview
@ -621,19 +680,51 @@ async def get_agent(config: RunnableConfig) -> Pregel:
profile_login = resolve_github_login(config)
configurable = (config or {}).get("configurable") or {}
prompt_default_repo = await _resolve_prompt_default_repo(configurable)
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
# Commit identity must follow the SAME default-bot decision as the token
# (SH-IDSPLIT-01): by default slack/dashboard/schedule runs author commits as the
# app bot, so resolve the triggering USER's git identity ONLY when authoring as the
# user (the author_prs_as_user opt-in, or a non-default source). Otherwise leave it
# None so construct_system_prompt sets the bot identity (OPEN_SWE_BOT_NAME/EMAIL) and
# commits don't get mis-attributed to a human who didn't write them.
if configurable.get("source") in ("slack", "dashboard", "schedule"):
_author_as_user = bool(
isinstance(profile_login, str)
and profile_login.strip()
and profile_author_prs_as_user(await load_profile(profile_login.strip()))
)
else:
_author_as_user = True
async def _no_triggering_identity() -> Any:
return None
if _author_as_user:
triggering_user_identity_task = asyncio.create_task(
asyncio.to_thread(resolve_triggering_user_identity, config, github_token)
)
else:
triggering_user_identity_task = asyncio.create_task(_no_triggering_identity())
sandbox_task = asyncio.create_task(
ensure_sandbox_for_thread(thread_id, repo=prompt_default_repo)
)
team_defaults_task = asyncio.create_task(get_team_default_model_pair("agent"))
profile_task = asyncio.create_task(load_profile(profile_login)) if profile_login else None
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
triggering_user_identity_task,
sandbox_task,
team_defaults_task,
)
try:
triggering_user_identity, sandbox_backend, team_defaults = await asyncio.gather(
triggering_user_identity_task,
sandbox_task,
team_defaults_task,
)
except SandboxRepoMismatchError as exc:
# Repo-binding refusal at the run boundary: log for alarming and surface the
# already-sanitized terminal error (no sandbox/token internals) to the caller,
# rather than letting an opaque deep-stack exception crash-loop the worker.
logger.error("Refusing agent run for thread %s: %s", thread_id, exc)
for pending in (triggering_user_identity_task, team_defaults_task, profile_task):
if pending is not None and not pending.done():
pending.cancel()
raise RuntimeError(str(exc)) from exc
profile = await profile_task if profile_task is not None else None
del github_token
@ -716,9 +807,9 @@ async def get_agent(config: RunnableConfig) -> Pregel:
fallback_model_id = os.environ.get("LLM_FALLBACK_MODEL_ID") or fallback_model_id_for(model_id)
fallback_middleware: list[Any] = []
if fallback_model_id and fallback_model_id != model_id:
fallback_kwargs: ModelKwargs = {"max_tokens": DEFAULT_LLM_MAX_TOKENS}
if fallback_model_id.startswith("openai:"):
fallback_kwargs["reasoning"] = DEFAULT_LLM_REASONING
fallback_kwargs = provider_model_kwargs(
fallback_model_id, None, max_tokens=DEFAULT_LLM_MAX_TOKENS
)
fallback_middleware.append(
ModelFallbackMiddleware(make_model(fallback_model_id, **fallback_kwargs))
)

View file

@ -25,11 +25,12 @@ _REFERENCES_HEADING = "## References"
async def _resolve_pr_author_token() -> tuple[str | None, str]:
"""Return ``(token, kind)`` for opening the PR.
Prefers the triggering user's OAuth token (so the PR is created *as them*)
for Slack/dashboard runs with a mapped GitHub login, resolving it by login
from the dashboard OAuth store. Falls back to the GitHub App installation
token (creator = open-swe[bot]) for GitHub-triggered runs, unmapped users,
or bot-token-only deployments — preserving today's behavior.
DEFAULT: open the PR as the GitHub App bot ``seahaven-openswe[bot]`` (the
installation token) for every source, so Slack/dashboard PRs are attributed
to the app — matching GitHub-issue runs and making the self-review 422
impossible by construction. OPT-IN: when the triggering user's profile has
``author_prs_as_user: true``, open Slack/dashboard PRs as that user (their
per-user OAuth token, resolved by login from the dashboard OAuth store).
The token is resolved by login rather than read from the shared thread
metadata: Slack thread ids are shared across a conversation, so a cached
@ -40,12 +41,19 @@ async def _resolve_pr_author_token() -> tuple[str | None, str]:
github_login = configurable.get("github_login")
if source in _USER_TOKEN_SOURCES and isinstance(github_login, str) and github_login.strip():
from ..dashboard.profiles import get_valid_access_token
login = github_login.strip()
from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
user_token = await get_valid_access_token(github_login.strip())
if user_token:
return user_token, "user"
logger.info("No valid user token for %s; opening PR as open-swe[bot]", github_login.strip())
if profile_author_prs_as_user(await load_profile(login)):
from ..dashboard.profiles import get_valid_access_token
user_token = await get_valid_access_token(login)
if user_token:
return user_token, "user"
logger.info(
"author_prs_as_user set but no valid user token for %s; opening PR as the app bot",
login,
)
return await get_github_app_installation_token(), "bot"

View file

@ -92,7 +92,7 @@ async def _reply_to_finding_thread_async(
"kind": "bot_reply",
"github_comment_id": reply_id if isinstance(reply_id, int) else None,
"github_parent_comment_id": comment_id,
"author": "open-swe[bot]",
"author": "seahaven-openswe[bot]",
"body": body.strip(),
"created_at": "",
"needs_reassessment": False,

View file

@ -192,11 +192,18 @@ async def get_github_token_for_user(ls_user_id: str, tenant_id: str) -> dict[str
return result
if auth_url:
return {"auth_url": auth_url}
return {"error": f"Unexpected auth result: {response_data}"}
# Log the full upstream body server-side only; the returned error becomes a
# user-facing Slack/Linear comment, so never echo the raw response body.
logger.error(
"GitHub auth returned an unexpected result (no token/url): %s", response_data
)
return {"error": "GitHub auth returned an unexpected result"}
except httpx.HTTPStatusError as e:
# Log the full upstream body server-side only; the returned error becomes a
# user-facing Slack/Linear comment, so never echo the raw response text.
logger.error("GitHub auth API HTTP error: %s - %s", e.response.status_code, e.response.text)
return {"error": f"HTTP error: {e.response.status_code} - {e.response.text}"}
return {"error": f"GitHub auth failed (status {e.response.status_code})"}
except Exception as e: # noqa: BLE001
logger.error("GitHub auth API call failed: %s: %s", type(e).__name__, str(e))
return {"error": str(e)}
@ -286,10 +293,19 @@ async def leave_failure_comment(
raise ValueError(f"Unknown source: {source}")
def _current_repo() -> Any:
"""Best-effort read of the run's repo (owner/name) for cache binding."""
try:
configurable = get_config().get("configurable", {})
except Exception:
return None
return configurable.get("repo") if isinstance(configurable, dict) else None
def _cache_resolved_github_token(
thread_id: str, token: str, expires_at: str | None = None
) -> tuple[str, str | None]:
cache_github_token_for_thread(thread_id, token, expires_at=expires_at)
cache_github_token_for_thread(thread_id, token, expires_at=expires_at, repo=_current_repo())
return token, expires_at
@ -383,7 +399,13 @@ async def _resolve_dashboard_user_token(
async def _resolve_bot_installation_token(thread_id: str) -> tuple[str, str | None]:
"""Get a GitHub App installation token and cache it for the thread."""
"""Get a GitHub App installation token and cache it for the thread.
AUTHZ-003 (accepted): in bot-token-only mode every run shares one GitHub App
installation token, so its blast radius is the whole installation rather than
a single user. This is a documented, accepted prod posture for this
single-tenant deployment, not a defect.
"""
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
if not bot_token:
raise RuntimeError(
@ -418,33 +440,40 @@ async def resolve_github_token(config: RunnableConfig, thread_id: str) -> tuple[
github_login = configurable.get("github_login")
# Per-user OAuth from the dashboard store wins even in bot-token-only mode,
# for sources that carry a mapped GitHub login (Slack, dashboard). This is
# what lets the agent open PRs as the triggering user.
# DEFAULT: Slack/dashboard/schedule runs use the GitHub App installation token,
# so all git/gh operations + the PR come in as the app `seahaven-openswe[bot]`
# (deterministic; matches GitHub-issue runs; eliminates the self-review 422).
# OPT-IN: a profile with `author_prs_as_user: true` restores the per-user OAuth
# token so the run is attributed to the triggering user.
if (
source in ("slack", "dashboard", "schedule")
and isinstance(github_login, str)
and github_login.strip()
):
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
raise RuntimeError(str(exc)) from exc
if user_token is not None:
return user_token
# No valid user token. In bot-token-only mode fall back to the bot so the
# deployment stays functional; otherwise block and require auth.
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
raise GitHubUserAuthRequired(source, github_login)
from ..dashboard.agent_overrides import load_profile, profile_author_prs_as_user
if profile_author_prs_as_user(await load_profile(github_login.strip())):
try:
user_token = await _resolve_dashboard_user_token(thread_id, github_login)
except ValueError as exc:
logger.error("GitHub auth failed for thread %s: %s", thread_id, str(exc))
raise RuntimeError(str(exc)) from exc
if user_token is not None:
return user_token
# Opt-in set but no valid user token: in bot-token-only mode fall back
# to the bot; otherwise block and require auth.
if not is_bot_token_only_mode():
raise GitHubUserAuthRequired(source, github_login)
return await _resolve_bot_installation_token(thread_id)
if is_bot_token_only_mode():
return await _resolve_bot_installation_token(thread_id)
try:
if source == "github":
cached_token, cached_expires_at = await get_github_token_from_thread(thread_id)
cached_token, cached_expires_at = await get_github_token_from_thread(
thread_id, expected_repo=configurable.get("repo")
)
if cached_token:
return cached_token, cached_expires_at
from ..dashboard.user_mappings import email_for_login

View file

@ -10,11 +10,18 @@ import httpx
logger = logging.getLogger(__name__)
OPEN_SWE_BOT_NAME = "open-swe[bot]"
# Use the open-swe user noreply address: the bot's numeric noreply
# (215916821+open-swe[bot]@...) doesn't resolve to a GitHub account Vercel
# accepts, which broke preview deploys on commits carrying this co-author.
OPEN_SWE_BOT_EMAIL = "open-swe@users.noreply.github.com"
# Sea Haven fork identity: commits AND PRs come in as our GitHub App bot
# `seahaven-openswe[bot]` (user id 296972425) so Slack/dashboard/schedule runs are
# attributed to the app, not the triggering user (deterministic; eliminates the
# self-review 422). The numeric noreply is the canonical GitHub form.
# NOTE (Adam, 2026-06-29 — ACCEPTED RISK): the `<id>+<login>@users.noreply` form
# may NOT resolve to a GitHub account Vercel preview deploys accept (the upstream
# `open-swe[bot]` hit exactly this and worked around it with a non-numeric
# address). We deliberately accept that risk here in exchange for a consistent
# bot identity across commits + PRs. If Vercel preview deploys on a target repo
# start rejecting our commits, this is the cause — revert to a resolvable address.
OPEN_SWE_BOT_NAME = "seahaven-openswe[bot]"
OPEN_SWE_BOT_EMAIL = "296972425+seahaven-openswe[bot]@users.noreply.github.com"
@dataclass(frozen=True)

View file

@ -10,7 +10,12 @@ from .github_app import get_github_app_installation_token
logger = logging.getLogger(__name__)
INTERNAL_BOT_LOGINS: frozenset[str] = frozenset({"open-swe[bot]", "openswe-dev[bot]"})
# Bot logins whose webhook events are our OWN actions — never re-process them
# (self-trigger guard). Sea Haven's App is `seahaven-openswe[bot]`; the upstream
# `open-swe[bot]` / `openswe-dev[bot]` are kept for historical/test events.
INTERNAL_BOT_LOGINS: frozenset[str] = frozenset(
{"seahaven-openswe[bot]", "open-swe[bot]", "openswe-dev[bot]"}
)
async def is_user_active_org_member(username: str, org: str) -> bool:

View file

@ -17,22 +17,42 @@ _GITHUB_TOKEN_EXPIRY_SKEW_SECONDS = 60
# Hard cap on how long an entry stays cached regardless of the token's own
# expiry, so entries for threads that are never read again don't accumulate.
_GITHUB_TOKEN_MAX_TTL = timedelta(hours=24)
# thread_id -> (token, token_expires_at, cached_at)
_GITHUB_TOKEN_CACHE: dict[str, tuple[str, str | None, datetime]] = {}
# thread_id -> (token, token_expires_at, cached_at, bound_repo). ``bound_repo``
# ("owner/name") binds the entry to the repo it was resolved for so a colliding
# thread_id originating from a different repo cannot reuse another repo's token.
_GITHUB_TOKEN_CACHE: dict[str, tuple[str, str | None, datetime, str | None]] = {}
class GitHubAuthError(Exception):
"""Raised when a GitHub call returns 401, signalling a stale/revoked token."""
def repo_cache_key(repo: Any) -> str | None:
"""Normalize a repo dict/string to a casefolded ``owner/name`` (None if unknown).
GitHub owner/name are case-insensitive, so the key is casefolded on both the
write (binding) and read (compare) sides to keep ``Org/Repo`` and ``org/repo``
a single repo and avoid spurious cross-repo mismatches.
"""
if isinstance(repo, str):
cleaned = repo.strip()
return cleaned.casefold() or None
if isinstance(repo, Mapping):
owner = repo.get("owner")
name = repo.get("name")
if isinstance(owner, str) and isinstance(name, str) and owner and name:
return f"{owner}/{name}".casefold()
return None
def cache_github_token_for_thread(
thread_id: str, token: str, expires_at: str | None = None
thread_id: str, token: str, expires_at: str | None = None, *, repo: Any = None
) -> None:
"""Cache a GitHub token in process for the current thread."""
if not thread_id or not token:
return
now = datetime.now(UTC)
_GITHUB_TOKEN_CACHE[thread_id] = (token, expires_at, now)
_GITHUB_TOKEN_CACHE[thread_id] = (token, expires_at, now, repo_cache_key(repo))
_evict_expired(now=now)
@ -78,24 +98,37 @@ def _evict_expired(*, now: datetime | None = None) -> None:
current = now or datetime.now(UTC)
stale = [
tid
for tid, (_token, expires_at, cached_at) in _GITHUB_TOKEN_CACHE.items()
for tid, (_token, expires_at, cached_at, _repo) in _GITHUB_TOKEN_CACHE.items()
if _entry_expired(expires_at, cached_at, now=current)
]
for tid in stale:
_GITHUB_TOKEN_CACHE.pop(tid, None)
def _cached_token_if_fresh(thread_id: str | None) -> tuple[str | None, str | None]:
def _cached_token_if_fresh(
thread_id: str | None, *, expected_repo: Any = None
) -> tuple[str | None, str | None]:
if not thread_id:
return None, None
cached = _GITHUB_TOKEN_CACHE.get(thread_id)
if not cached:
return None, None
token, expires_at, cached_at = cached
token, expires_at, cached_at, bound_repo = cached
if _entry_expired(expires_at, cached_at, now=datetime.now(UTC)):
_GITHUB_TOKEN_CACHE.pop(thread_id, None)
logger.info("Cached GitHub token for thread %s has expired; re-resolving", thread_id)
return None, None
expected = repo_cache_key(expected_repo)
if expected and bound_repo and expected != bound_repo:
_GITHUB_TOKEN_CACHE.pop(thread_id, None)
logger.warning(
"Cached GitHub token for thread %s is bound to repo %s, not %s; "
"refusing cross-repo reuse",
thread_id,
bound_repo,
expected,
)
return None, None
return token, expires_at
@ -107,16 +140,27 @@ def _thread_id_from_config(run_config: Mapping[str, Any]) -> str | None:
return thread_id if isinstance(thread_id, str) and thread_id else None
def _repo_from_config(run_config: Mapping[str, Any]) -> Any:
configurable = run_config.get("configurable", {})
if not isinstance(configurable, Mapping):
return None
return configurable.get("repo")
def get_github_token(run_config: Mapping[str, Any] | None = None) -> str | None:
"""Resolve the current thread's GitHub token from process memory."""
resolved = run_config if run_config is not None else get_config()
token, _expires_at = _cached_token_if_fresh(_thread_id_from_config(resolved))
token, _expires_at = _cached_token_if_fresh(
_thread_id_from_config(resolved), expected_repo=_repo_from_config(resolved)
)
return token
async def get_github_token_from_thread(thread_id: str) -> tuple[str | None, str | None]:
async def get_github_token_from_thread(
thread_id: str, *, expected_repo: Any = None
) -> tuple[str | None, str | None]:
"""Resolve the current process's cached GitHub token for a thread."""
return _cached_token_if_fresh(thread_id)
return _cached_token_if_fresh(thread_id, expected_repo=expected_repo)
async def invalidate_cached_github_token(thread_id: str) -> None:

View file

@ -45,6 +45,8 @@ class ModelKwargs(TypedDict, total=False):
temperature: float | None
max_retries: int | None
model_kwargs: dict[str, object] | None
additional_model_request_fields: dict[str, object] | None
region_name: str | None
_ANTHROPIC_EFFORTS: set[AnthropicEffort] = {"low", "medium", "high", "xhigh", "max"}
@ -57,6 +59,13 @@ def make_model(model_id: str, **kwargs: Unpack[ModelKwargs]):
if model_id.startswith("openai:"):
model_kwargs["base_url"] = OPENAI_RESPONSES_WS_BASE_URL
model_kwargs["use_responses_api"] = True
elif model_id.startswith("bedrock_converse:"):
# Resolve region with the same precedence validate_local_dev_llm_config accepts
# (AWS_REGION or AWS_DEFAULT_REGION), so the validated value is the one actually used.
model_kwargs.setdefault(
"region_name",
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") or "us-east-1",
)
return init_chat_model(model=model_id, **model_kwargs)
@ -64,14 +73,14 @@ def make_model(model_id: str, **kwargs: Unpack[ModelKwargs]):
def fallback_model_id_for(primary_model_id: str) -> str | None:
"""Return the cross-provider fallback model id for a given primary, if any.
Anthropic primaries fall back to OpenAI and vice versa. Returns ``None``
when the provider has no configured cross-provider fallback (e.g. Google,
local, or self-hosted providers we don't want to silently route off-host).
Bedrock (Claude) primaries fall back to Fireworks and vice versa. Returns
``None`` when the provider has no configured cross-provider fallback (e.g.
local or self-hosted providers we don't want to silently route off-host).
"""
if primary_model_id.startswith("anthropic:"):
return "openai:gpt-5.5"
if primary_model_id.startswith("openai:"):
return "anthropic:claude-opus-4-5"
if primary_model_id.startswith("bedrock_converse:"):
return "fireworks:accounts/fireworks/models/deepseek-v4-pro"
if primary_model_id.startswith("fireworks:"):
return "bedrock_converse:us.anthropic.claude-opus-4-8"
return None
@ -179,6 +188,19 @@ def provider_model_kwargs(
effort = anthropic_effort_for(profile_effort)
if effort is not None:
kwargs["effort"] = effort
elif model_id.startswith("bedrock_converse:"):
# Opus 4.7+ on Bedrock rejects thinking.type "enabled"/budget_tokens with a
# ValidationException; it requires adaptive thinking plus output_config.effort,
# passed through Converse's additional_model_request_fields.
fields: dict[str, object] = {}
thinking = anthropic_thinking_for(profile_effort)
if thinking is not None:
fields["thinking"] = thinking
effort = anthropic_effort_for(profile_effort)
if effort is not None:
fields["output_config"] = {"effort": effort}
if fields:
kwargs["additional_model_request_fields"] = fields
elif model_id.startswith("google_genai:") and is_gemini_3_family(model_id):
thinking_level = google_thinking_level_for(profile_effort)
if thinking_level is not None:
@ -204,13 +226,9 @@ def validate_local_dev_llm_config() -> None:
model_id = os.environ.get("LLM_MODEL_ID", DEFAULT_MODEL_ID)
if model_id.startswith("openai:") and not os.environ.get("OPENAI_API_KEY"):
raise ValueError(f"OPENAI_API_KEY is required for configured model {model_id}")
elif model_id.startswith("anthropic:") and not os.environ.get("ANTHROPIC_API_KEY"):
raise ValueError(f"ANTHROPIC_API_KEY is required for configured model {model_id}")
elif model_id.startswith("google_genai:") and not os.environ.get("GOOGLE_API_KEY"):
raise ValueError(f"GOOGLE_API_KEY is required for configured model {model_id}")
elif model_id.startswith("groq:") and not os.environ.get("GROQ_API_KEY"):
raise ValueError(f"GROQ_API_KEY is required for configured model {model_id}")
if model_id.startswith("bedrock_converse:") and not (
os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
):
raise ValueError(f"AWS_REGION is required for configured model {model_id}")
elif model_id.startswith("fireworks:") and not os.environ.get("FIREWORKS_API_KEY"):
raise ValueError(f"FIREWORKS_API_KEY is required for configured model {model_id}")

View file

@ -19,6 +19,7 @@ from deepagents.backends.protocol import (
)
from langgraph.config import get_config
from .github_token import repo_cache_key
from .sandbox import create_sandbox
logger = logging.getLogger(__name__)
@ -29,6 +30,9 @@ class SandboxBackendProxy(SandboxBackendProtocol):
def __init__(self, backend: SandboxBackendProtocol) -> None:
self._backend = backend
# "owner/name" of the repo this sandbox is bound to, used to refuse
# reuse by a different repo presenting a colliding thread_id.
self.bound_repo: str | None = None
@property
def current(self) -> SandboxBackendProtocol:
@ -131,21 +135,44 @@ def unwrap_sandbox_backend(sandbox_backend: SandboxBackendProtocol) -> SandboxBa
def set_sandbox_backend(
thread_id: str,
sandbox_backend: SandboxBackendProtocol,
*,
repo: str | None = None,
) -> SandboxBackendProxy:
if isinstance(sandbox_backend, SandboxBackendProxy):
if repo:
sandbox_backend.bound_repo = repo
SANDBOX_BACKENDS[thread_id] = sandbox_backend
return sandbox_backend
existing = SANDBOX_BACKENDS.get(thread_id)
if isinstance(existing, SandboxBackendProxy):
existing.replace_backend(sandbox_backend)
if repo:
existing.bound_repo = repo
return existing
proxy = SandboxBackendProxy(sandbox_backend)
if repo:
proxy.bound_repo = repo
SANDBOX_BACKENDS[thread_id] = proxy
return proxy
async def get_bound_repo_from_metadata(thread_id: str) -> str | None:
"""Fetch the repo (``owner/name``) this thread's sandbox is bound to."""
try:
config = get_config()
except Exception:
return None
metadata = config.get("metadata", {})
if not isinstance(metadata, dict):
return None
bound_repo = metadata.get("bound_repo")
# Casefold on read so legacy metadata written before normalization (e.g.
# ``Org/Repo``) still compares equal to the casefolded current repo key.
return repo_cache_key(bound_repo) if isinstance(bound_repo, str) and bound_repo else None
def clear_sandbox_backend(thread_id: str) -> None:
SANDBOX_BACKENDS.pop(thread_id, None)

View file

@ -1339,8 +1339,32 @@ async def process_slack_mention(event_data: dict[str, Any], repo_config: dict[st
)
LINEAR_WEBHOOK_MAX_AGE_SECONDS = 60
def _linear_timestamp_is_fresh(body: bytes) -> bool:
"""Reject replays: the signed payload's ``webhookTimestamp`` must be recent.
Linear includes ``webhookTimestamp`` (Unix milliseconds) inside the signed
body. Fail closed when it is missing or malformed.
"""
try:
ts_ms = json.loads(body)["webhookTimestamp"]
except (json.JSONDecodeError, KeyError, TypeError):
logger.warning("Linear webhook missing/invalid webhookTimestamp — rejecting")
return False
if not isinstance(ts_ms, (int, float)) or isinstance(ts_ms, bool):
logger.warning("Linear webhook webhookTimestamp is not numeric — rejecting")
return False
now_ms = datetime.now(UTC).timestamp() * 1000
if abs(now_ms - ts_ms) > LINEAR_WEBHOOK_MAX_AGE_SECONDS * 1000:
logger.warning("Linear webhook timestamp outside freshness window — rejecting")
return False
return True
def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
"""Verify the Linear webhook signature.
"""Verify the Linear webhook signature and replay-freshness window.
Args:
body: Raw request body bytes
@ -1348,15 +1372,17 @@ def verify_linear_signature(body: bytes, signature: str, secret: str) -> bool:
secret: The webhook signing secret
Returns:
True if signature is valid, False otherwise
True if the signature is valid AND the signed timestamp is fresh.
"""
if not secret:
logger.warning("LINEAR_WEBHOOK_SECRET is not configured — rejecting webhook request")
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return False
return hmac.compare_digest(expected, signature)
return _linear_timestamp_is_fresh(body)
@app.post("/webhooks/linear")
@ -2920,31 +2946,36 @@ async def process_github_autofix_review(payload: dict[str, Any], event_type: str
)
async def _refresh_thread_github_token_after_401(thread_id: str, email: str) -> str | None:
async def _refresh_thread_github_token_after_401(
thread_id: str, email: str, *, repo: dict[str, str] | None = None
) -> str | None:
"""Invalidate the cached token after a 401 and try to resolve a fresh one."""
logger.warning(
"GitHub returned 401 for thread %s; invalidating cached token and re-resolving",
thread_id,
)
await invalidate_cached_github_token(thread_id)
return await _get_or_resolve_thread_github_token(thread_id, email)
return await _get_or_resolve_thread_github_token(thread_id, email, repo=repo)
async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
async def _get_or_resolve_thread_github_token(
thread_id: str, email: str, *, repo: dict[str, str] | None = None
) -> str | None:
"""Resolve and cache a GitHub token for a thread when available.
In bot-token-only mode, returns a fresh GitHub App installation token
instead of resolving per-user OAuth tokens.
instead of resolving per-user OAuth tokens. ``repo`` (owner/name) binds the
cached entry so a colliding thread_id from a different repo cannot reuse it.
"""
if is_bot_token_only_mode():
bot_token, expires_at = await get_github_app_installation_token_with_expiry()
if bot_token:
cache_github_token_for_thread(thread_id, bot_token, expires_at=expires_at)
cache_github_token_for_thread(thread_id, bot_token, expires_at=expires_at, repo=repo)
return bot_token
logger.warning("Bot-token-only mode but GitHub App token unavailable")
return None
github_token, _expires_at = await get_github_token_from_thread(thread_id)
github_token, _expires_at = await get_github_token_from_thread(thread_id, expected_repo=repo)
if github_token:
return github_token
@ -2955,7 +2986,10 @@ async def _get_or_resolve_thread_github_token(thread_id: str, email: str) -> str
expires_at = auth_result.get("expires_at")
cache_github_token_for_thread(
thread_id, github_token, expires_at=expires_at if isinstance(expires_at, str) else None
thread_id,
github_token,
expires_at=expires_at if isinstance(expires_at, str) else None,
repo=repo,
)
return github_token
@ -3017,7 +3051,7 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
email = await email_for_login(github_login) or ""
if email:
github_token = await _get_or_resolve_thread_github_token(thread_id, email)
github_token = await _get_or_resolve_thread_github_token(thread_id, email, repo=repo_config)
else:
logger.warning("No email mapping for GitHub user '%s', skipping", github_login)
return
@ -3037,7 +3071,9 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
node_id=node_id,
)
except GitHubAuthError:
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
github_token = await _refresh_thread_github_token_after_401(
thread_id, email, repo=repo_config
)
if not github_token:
logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
return
@ -3059,7 +3095,9 @@ async def process_github_pr_comment(payload: dict[str, Any], event_type: str) ->
repo_config, pr_number, token=github_token
)
except GitHubAuthError:
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
github_token = await _refresh_thread_github_token_after_401(
thread_id, email, repo=repo_config
)
if not github_token:
logger.warning("Re-auth failed for thread %s after 401; skipping", thread_id)
return
@ -3141,7 +3179,7 @@ async def process_github_review_finding_reply(payload: dict[str, Any]) -> None:
sender = payload.get("sender", {})
sender_login = sender.get("login") if isinstance(sender, dict) else None
if sender_login == "open-swe[bot]":
if sender_login in INTERNAL_BOT_LOGINS:
return
repo = payload.get("repository", {})
@ -3297,7 +3335,7 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
thread_id = generate_thread_id_from_github_issue(issue_id)
existing_thread = await _thread_exists(thread_id)
github_token = await _get_or_resolve_thread_github_token(thread_id, email)
github_token = await _get_or_resolve_thread_github_token(thread_id, email, repo=repo_config)
app_token = await get_github_app_installation_token()
reaction_token = github_token or app_token
comment = payload.get("comment", {})
@ -3314,7 +3352,9 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
token=reaction_token,
)
except GitHubAuthError:
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
github_token = await _refresh_thread_github_token_after_401(
thread_id, email, repo=repo_config
)
reaction_token = github_token or app_token
reacted = False
if reaction_token:
@ -3348,7 +3388,9 @@ async def process_github_issue(payload: dict[str, Any], event_type: str) -> None
repo_config, issue_number, token=github_token or app_token
)
except GitHubAuthError:
github_token = await _refresh_thread_github_token_after_401(thread_id, email)
github_token = await _refresh_thread_github_token_after_401(
thread_id, email, repo=repo_config
)
comments = await fetch_issue_comments(
repo_config, issue_number, token=github_token or app_token
)

View file

@ -1,118 +1,280 @@
# Sea Haven — Open SWE self-hosted deployment
# Sea Haven — Open SWE deployment runbook
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.**
How this fork is deployed at Sea Haven. **PROD is LIVE as of 2026-06-29.** The
runtime is the **stock LangGraph dev server** (not the Aegra path — see
[Aegra](#aegra-deferred)), running on a self-hosted ARM64 EC2 box behind the
shared `seahaven-com` ALB.
## Topology
Account `328440206208`, region `us-east-1`. Internal addresses, ARNs, snapshot
ids, and account-scoped values that are sensitive are shown as `<PLACEHOLDERS>`;
the real values live in the private IT docs (Confluence "AWS Architecture Map",
id 1540098) and in AWS — **do not commit them to this public fork.**
```
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)
```
This is the canonical deploy runbook. The CDK details live in
[`infra/README.md`](../../infra/README.md); the rotation procedure in
[`ROTATION.md`](ROTATION.md).
- 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.
## Live prod facts (2026-06-29)
## 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. |
| Dashboard | `https://openswe.seahaven.com` |
| Webhooks | `https://hooks.seahaven.com/webhooks/*` |
| Ingress | shared internet-facing ALB `app/seahaven-com` → target group `open-swe-prod-tg` → EC2 `i-08a729e50779c4b07` (`t4g.large`, ARM64) on nginx `:80` |
| Backend | `langgraph dev` bound to `127.0.0.1:2024` (loopback only); nginx is the sole ingress |
| CDK stacks | `open-swe-iam` (OIDC roles) · `open-swe-dev` · `open-swe-prod` |
### 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.
Dev mirrors prod with `-dev` hosts (`openswe-dev.seahaven.com` /
`hooks-dev.seahaven.com`), a `t4g.medium` box, and no GitHub-App/Slack/webhook
integration (it is a deployment-validation env, not a live-triggered agent).
## Build & deploy the dashboard (`ui/`)
> The retired on-prem `*.seahavenind.com` ALB routing and DNS were removed on
> 2026-06-29; prod is now live exclusively on `*.seahaven.com`.
`ui/` is a **TanStack Start + Nitro** app (build with `bun`, not plain Vite):
## Hosting model
```
GitHub / Slack ──▶ hooks.seahaven.com ──┐
│ (shared ALB :443, host+path rules)
Browser ─────────▶ openswe.seahaven.com ──┤
▼
ALB app/seahaven-com ──▶ open-swe-prod-tg ──▶ EC2 box :80 (nginx)
├─ nginx — SPA + scoped proxy
│ /dashboard/api/* and /webhooks/*
└─ langgraph dev 127.0.0.1:2024
└─▶ LangSmith cloud sandbox (build/git/PR)
```
- A **single** VPC and a **single** internet-facing ALB (`app/seahaven-com`) are
shared with the on-prem `seahaven-site` stack. open-swe **imports** the VPC,
ALB SG, `:443` listener, and `seahaven.com` zone — it never owns/mutates them;
it only adds its own instance SG, a standalone ALB-egress rule, two listener
rules, a target group, and Route53 aliases.
- The EC2 box is in a **private** subnet (us-east-1a, same AZ as the single NAT
for in-AZ egress). It is reachable **only** from the shared ALB SG on `:80`.
- **nginx is the security boundary.** It serves the static dashboard SPA and
proxies exactly two prefixes to `:2024` — `/dashboard/api/*` and `/webhooks/*`.
The unauthenticated LangGraph API (`/threads`, `/runs`, `/assistants`,
`/store`) is never proxied; those paths return the SPA shell. `:2024` is
loopback-only and never network-reachable, even inside the SG.
- **Webhooks** ride listener rules below the on-prem host-agnostic `/webhooks/*`
rule (priority 2 dev / 3 prod, host-scoped to the open-swe hosts) so they reach
the open-swe box and never steal an on-prem host's webhooks.
The box holds **no durable state of its own**: secrets/config are materialized to
a tmpfs `.env` at boot, the app artifact is pulled from S3, and the in-memory
LangGraph store is re-seeded on every start. Replacement is tolerated; there is no
RETAIN volume.
---
## Deploy pipeline (end to end)
Two independent CD lanes, both OIDC-only (no static keys), both with a manual
approval gate on prod via the GitHub **`prod` Environment** (required reviewer:
Adam). The `environment: prod` declaration both fires the approval gate and makes
the OIDC subject `…:environment:prod`, which is the only subject the prod deploy
roles trust — so a dev-branch token can never reach prod.
### (a) Infra CD — `cd-infra.yml`
Deploys the CDK stacks. Path-filtered to `infra/**`.
```
push to dev → Infra CI (tsc + jest + cdk synth) → cdk deploy OpenSweDevStack (AUTO, CI-green-gated)
push to main → Infra CI → cdk deploy OpenSweProdStack (manual approval: env "prod")
```
- Roles: `githubdeploy-open-swe-infra-{dev,prod}` (in the `open-swe-iam` stack;
set as repo variables `AWS_DEPLOY_ROLE_INFRA_{DEV,PROD}`).
- It targets **one stack explicitly per env** (`cdk deploy OpenSweDevStack` /
`OpenSweProdStack`), not `cdk deploy --all`, so a single-env push can never
deploy the other env or the shared IAM stack.
- The shared `open-swe-iam` stack (owns both envs' OIDC deploy roles) is **not**
deployed by CD — it is a privileged, human-gated apply.
**Stack order on a clean account:** `open-swe-iam` first (creates the OIDC roles;
set the repo deploy-role variables and configure the `prod` Environment reviewer
from its outputs), then `open-swe-dev`, then `open-swe-prod`.
### (b) Seed the config store — `put-config.sh <env>`
Run **after** `cdk deploy open-swe-<env>` and **before** the box first boots. CDK
creates the value-less Secrets Manager shells (`open-swe-<env>/<VAR>`) and the
IaC-managed SSM params (`/open-swe-<env>/<VAR>`); `put-config.sh` populates the
secret values plus the out-of-band SSM params that cannot live in IaC.
```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
deploy/seahaven/put-config.sh <dev|prod> # set each value inline, via OPENSWE_PUT_<VAR>, or from a vault
deploy/seahaven/fetch-config.sh <dev|prod> # (on the box) fail-fast verify before first start
```
Served as a static SPA (per `ui/vercel.json`); the Nitro `.output/server` is unused.
`put-config.sh` ships `<FILL>` placeholders only — **no real secret values are
committed**. It does not touch the IaC-managed SSM params (CDK owns those).
## Install / wire-up checklist
**13 prod boot-required vars** — `fetch-config.sh` fail-fasts (refuses to write a
partial `.env`, the unit does not start) if any are missing/empty:
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.
- **9 secrets** (Secrets Manager `open-swe-prod/<VAR>`): `DASHBOARD_JWT_SECRET`,
`TOKEN_ENCRYPTION_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
`LANGSMITH_API_KEY_PROD`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`,
`GITHUB_WEBHOOK_SECRET`, `SLACK_SIGNING_SECRET`.
- **4 SSM params** (`/open-swe-prod/<VAR>`): `DEFAULT_SANDBOX_SNAPSHOT_ID`,
`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, `GITHUB_APP_CLIENT_ID`.
(`ANTHROPIC_API_KEY` + `OPENAI_API_KEY` are required because that is the seeded
cross-family pair; the active set follows `REQUIRED_PROVIDER_KEYS`. The
`LANGSMITH_API_KEY_PROD` + `DEFAULT_SANDBOX_SNAPSHOT_ID` pair is required because
`SANDBOX_TYPE=langsmith`.) Dev boots without the GitHub-App / Slack / webhook
secrets — it has no such integration.
`fetch-config.sh` runs as an `ExecStartPre=+` hook (root, only long enough to
write the `openswe`-owned `0600` tmpfs `.env`), reads all `/open-swe-<env>/*` SSM
params + all `open-swe-<env>/*` secrets via the instance role, and forces
`DEFAULT_REPO_OWNER` away from the upstream `langchain-ai` org.
### (c) App artifact deploy — `build-artifacts.yml`
Builds the release and rolls the box. Path-filtered to `agent/**`, `ui/**`,
`deploy/**`, `langgraph.json`, `pyproject.toml`, `uv.lock`.
```
push to dev → build SPA + package → open-swe-dev-assets/releases/ → SSM open-swe-dev-deploy (AUTO)
push to main → build SPA + package → open-swe-prod-assets/releases/ → SSM open-swe-prod-deploy (manual approval: env "prod")
```
1. The dashboard SPA is built **on the runner** (`bun run build` → vite →
`ui/.output/public`) — the box is small, so the memory-heavy build runs in CI.
2. `package-artifacts.sh` produces two tarballs: `spa.tar.gz` (built SPA) and
`app.tar.gz` (Python source tree — no `ui/`, no `.venv`).
3. Both are uploaded to S3 `open-swe-<env>-assets` under `releases/<sha>/`
(immutable, auditable) and mirrored to `releases/latest/` (what the box pulls).
4. CI fires the `open-swe-<env>-deploy` SSM document (tag-scoped to
`project=open-swe,env=<env>`), which runs `/opt/open-swe/bin/deploy.sh` on the
box: pull the release from S3, build a native-ARM64 venv with
`uv sync --frozen --no-dev`, extract the SPA to the nginx web root,
`systemctl restart open-swe.service`, reload nginx, then gate on
`systemctl is-active --quiet open-swe.service` (a non-active unit exits the
deploy non-zero).
Roles: `githubdeploy-open-swe-app-{dev,prod}` (repo variables
`AWS_DEPLOY_ROLE_APP_{DEV,PROD}`) — tag-scoped `ssm:SendCommand` on the deploy
document only (not the generic `AWS-RunShellScript`) + write to the env's S3
bucket.
Secrets/config are **not** fetched by `deploy.sh`; the `systemctl restart`'s
`ExecStartPre=fetch-config.sh` re-materializes the `.env` on every restart, so a
bad config surfaces as a failed unit.
### (d) dev → main promotion + rollback
**Promotion — `promote-dev-to-prod.yml`** (nightly cron `0 8 * * *` + manual
dispatch): mints a GitHub App installation token (a bypass actor on the `main`
ruleset), gates on **every check-run on the dev HEAD commit being completed and
passing**, then **fast-forward-only** pushes `dev` → `main`. A diverged `main`
fails loudly rather than force-updating. The push to `main` is what triggers the
prod lanes of `cd-infra.yml` / `build-artifacts.yml` (each still behind the `prod`
Environment approval). Re-gating via a PR on `main` would be redundant since the
commit already passed every check on dev.
**Rollback — `rollback.yml`** (manual dispatch, `env` + optional `sha`): re-points
`releases/latest/` at a prior release and re-fires the `open-swe-<env>-deploy` SSM
document — same fire/wait/gate path as a forward deploy, no rebuild.
```
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: env "prod")
sha=<commit> → restore that exact releases/<sha>/ instead
```
It reuses the existing `githubdeploy-open-swe-app-<env>` role (no new IAM).
---
## On-box layout (reference)
| Path | What |
|---|---|
| `open-swe.service` (systemd) | `langgraph dev --host 127.0.0.1 --port 2024 --no-browser --no-reload` as the unprivileged `openswe` user. In-memory runtime. |
| `fetch-config.sh` | `ExecStartPre=+` — materializes the tmpfs `.env` from Secrets Manager + SSM, fail-fast. |
| `seed_store.sh` | `ExecStartPost` — re-seeds `team_settings/default` + `user_mappings` (the in-memory store loses them on every restart). |
| nginx | SPA from `/var/www/open-swe`, proxy `/dashboard/api/` + `/webhooks/` → `127.0.0.1:2024`, `/healthz` → 200. |
| `deploy.sh` | the release procedure run on first boot (non-fatal) and by every SSM deploy. |
| CloudWatch logs | `/open-swe/<env>/{app,user-data,nginx-access,nginx-error}` at 30-day retention. |
The live systemd unit + nginx site are the **AMI templates**
(`deploy/ami/templates/open-swe.service`, `open-swe.nginx.conf`), rendered at
first boot by `deploy/ami/user-data.sh`. The AMI is the baked
`open-swe-base-arm64` image (Ubuntu 24.04 + uv/py3.12 + nginx + CW agent), pinned
by exact id in `infra/lib/constructs/ami-cache.ts`. There is intentionally **no**
on-box swapfile — the OOM-prone SPA build now runs in CI, not on the box.
> `deploy/seahaven/{nginx/openswe.conf,systemd/open-swe.service}` are the
> **retired on-prem VM** variants (run as `adam` from a home dir, bound `0.0.0.0`,
> Postgres-backed). They are kept only for on-prem-contrast reference and are not
> used by the AWS deployment.
### Models
Model selection is **store-driven**, not env. The `team_settings/default` store
doc wins (then per-user profile, then per-thread); `LLM_MODEL_ID` is only a
seed-time fallback. Defaults seeded by `seed_store.sh`:
- builder: `bedrock_converse:us.anthropic.claude-opus-4-8` (effort `high`)
- reviewer: `bedrock_converse:us.anthropic.claude-opus-4-8` (effort `high`) — set
`SEED_REVIEWER_MODEL` (or change it in the UI) to a Fireworks model if you want a
cross-family reviewer. Only ids present in `SUPPORTED_MODELS`
(`agent/dashboard/options.py`) are valid; OpenAI/Google models were removed in the
Bedrock/Fireworks migration.
- the `analyzer` graph is hardcoded to the code default and ignores team settings.
## 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.
Mention **`@openswe`** (or `@open-swe` / `@seahaven-openswe`) in a GitHub issue or
PR comment, a Linear comment, or a Slack thread. The commenter must have a
`user_mappings` entry (seeded by `seed_store.sh` from `CONFIGURED_ADMINS` /
`SEED_USER_MAPPINGS`) or the run is skipped.
## AWS variant — env-sourced config (.env from Secrets Manager + SSM)
Live integration endpoints (set in each provider's app config):
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 |
| Integration | URL |
|---|---|
| `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). |
| GitHub webhook | `https://hooks.seahaven.com/webhooks/github` |
| Slack events | `https://hooks.seahaven.com/webhooks/slack` (+ `/webhooks/slack/interactivity`) |
| Linear webhook | `https://hooks.seahaven.com/webhooks/linear` |
| GitHub OAuth callback | `https://openswe.seahaven.com/dashboard/api/auth/callback` |
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.
---
## Troubleshooting
**RETAIN secret-shell orphan on stack re-create.** The Secrets Manager shells use
`DeletionPolicy: Retain` + a fixed `open-swe-<env>/<VAR>` name. If a stack's first
create rolls back (or on a teardown/rebuild, a secret logical-id refactor, or
standing up a new env), the empty shells survive and keep their global names, so
every later create fails `AlreadyExists` — and a plain `delete-secret` does not
free the name (it stays reserved for the 7–30 day recovery window). Before
re-creating the stack, **force-delete the empty orphans** (only shells with no
value version — never a populated secret). Hit on prod 2026-06-29 (PR #51 deploy
failure). Full recovery command + rationale:
[`infra/README.md`](../../infra/README.md) (PR #52).
**`langgraph dev` won't start after a deploy.** `fetch-config.sh` fail-fasts on a
missing/empty required var and prints the offending variable **names** (never
values) to the unit journal. Confirm the 13 prod boot-required vars are populated
(`put-config.sh prod`), then `systemctl restart open-swe.service`.
**ALB target unhealthy.** The TG health check is `GET /healthz` on nginx `:80`
(static 200). nginx starts before the app on first boot, so an unhealthy target
usually means the box can't reach the ALB SG on `:80` (the standalone ALB-egress
rule) rather than an app fault.
---
## 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).
`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).
</content>

View file

@ -68,7 +68,7 @@ sudo systemctl restart open-swe.service
| **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. |
| **Model provider keys** (`FIREWORKS_API_KEY`; legacy `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `GOOGLE_API_KEY` / `GROQ_API_KEY`) | Standard API-key rotation: issue new key, update AWS, restart, revoke old. Bedrock (the seeded Claude builder + reviewer) authenticates via the **host IAM role — no API key**; the only fail-fast-required provider key is now `FIREWORKS_API_KEY` (fallback / subagents / any non-Claude model). 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. |

View file

@ -182,9 +182,9 @@ 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
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
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
)
@ -269,9 +269,10 @@ required=(
# 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}"
# so fetch-config cannot infer it from .env. Bedrock (Claude builder + reviewer)
# authenticates via the host IAM role — no API key; Fireworks (fallback / subagents /
# any non-Claude model) needs its key. Override with a comma list if the active models change.
IFS=',' read -r -a provider_keys <<<"${REQUIRED_PROVIDER_KEYS:-FIREWORKS_API_KEY}"
for k in "${provider_keys[@]}"; do
k="${k//[[:space:]]/}"
[ -n "$k" ] && required+=("$k")

View file

@ -1,10 +1,14 @@
# Open SWE dashboard frontend (TanStack Start SPA) + scoped API proxy.
# RETIRED on-prem VM variant — kept for on-prem-contrast reference only. The LIVE
# AWS nginx site is the AMI template deploy/ami/templates/open-swe.nginx.conf
# (rendered from an @@SERVER_NAME@@ token at first boot). See DEPLOYMENT.md.
#
# 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;
server_name openswe.seahaven.com;
root /var/www/openswe;
index _shell.html;

View file

@ -88,8 +88,7 @@ put_param() {
# 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 ANTHROPIC_API_KEY # optional — eval judge only (JUDGE_ANTHROPIC_API_KEY fallback); Bedrock builder/reviewer use the host IAM role
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)
@ -104,10 +103,8 @@ 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 FIREWORKS_API_KEY # active non-Claude key (fallback/subagents); Bedrock uses the host IAM role
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

View file

@ -77,9 +77,9 @@ pick() { # pick DEFAULT OVERRIDE_VALUE FILE_KEY...
# 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_MODEL="$(pick 'bedrock_converse:us.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_MODEL="$(pick 'bedrock_converse:us.anthropic.claude-opus-4-8' "${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

View file

@ -9,8 +9,9 @@ langsmith_project = "open-swe-evals"
# Leave blank to use LANGGRAPH_URL or local dev.
langgraph_url = ""
assistant_id = "reviewer"
# models: openai:gpt-5.5, anthropic:claude-opus-4-8, google_genai:gemini-3.5-flash
model_id = "google_genai:gemini-3.5-flash"
# models (post Bedrock/Fireworks migration): bedrock_converse:us.anthropic.claude-opus-4-8,
# or any fireworks:* id in agent/dashboard/options.py SUPPORTED_MODELS.
model_id = "bedrock_converse:us.anthropic.claude-opus-4-8"
reasoning_effort = "medium"
# score_mode:

View file

@ -66,6 +66,32 @@ never defaults to PascalCase; resource names follow `open-swe-<env>-*`.
The GitHub OIDC provider already exists account-wide (created for seahaven-site);
it is referenced by ARN, never re-created.
## CDK bootstrap qualifiers — per-env deploy isolation (B-1 / OSWE-IAC-01)
Each env's infra deploy role may assume **only its own bootstrap qualifier's**
roles, so a dev-branch token can never assume the bootstrap roles whose admin
`cfn-exec-role` deploys prod (closing the cross-env escalation that bypassed
prod's Environment approval gate). Mapping lives in `config.ts:bootstrapQualifier`:
| Env | Qualifier | Toolkit stack | Infra role assumes |
|---|---|---|---|
| dev | `oswedev` | `CDKToolkit-oswedev` | `cdk-oswedev-*` |
| prod | `hnb659fds` (default) | `CDKToolkit` | `cdk-hnb659fds-*` |
The dev stack synthesizes with `DefaultStackSynthesizer({ qualifier: "oswedev" })`
(`bin/app.ts`); prod uses the default. Bootstrap a new env qualifier with:
```bash
npx cdk bootstrap --qualifier <qual> --toolkit-stack-name CDKToolkit-<qual> \
--cloudformation-execution-policies arn:aws:iam::aws:policy/AdministratorAccess \
aws://328440206208/us-east-1
```
**Deploy order matters** when changing an env's qualifier: bootstrap the new
qualifier and deploy the env stack onto it **before** re-scoping that env's infra
role in `open-swe-iam` — otherwise a pipeline deploy with the re-scoped role would
fail to assume the not-yet-targeted bootstrap roles.
## Kebab-case naming Aspect
`KebabNamingAspect` (applied app-wide in `bin/app.ts`) fails synth via
@ -98,6 +124,27 @@ Three buckets:
> **Confirm** the 27-vs-29 count (code-only candidates not in the table:
> `USER_ID_API_KEY_MAP`, `JUDGE_ANTHROPIC_BASE_URL`).
> ⚠️ **`Retain` + fixed name orphans these shells on a failed FIRST create.**
> If the stack's initial create fails and rolls back, `Retain` keeps the shells
> instead of deleting them. The stack is then gone, but the secrets survive,
> still holding the global `open-swe-<env>/<VAR>` names — so every later create
> fails with `AlreadyExists`. A plain `delete-secret` does **not** clear it
> (the name stays reserved for the 7–30 day recovery window). This bites on a
> **teardown/rebuild, a secret logical-id change/refactor, or standing up a new
> env** — never on routine updates of an already-created stack. **Recovery —**
> before re-creating the stack, force-delete the *empty* orphans so the names
> free immediately:
> ```bash
> aws secretsmanager list-secrets --region us-east-1 \
> --filters Key=name,Values=open-swe-<env>/ \
> --query 'SecretList[].Name' --output text | tr '\t' '\n' | while read -r n; do
> aws secretsmanager delete-secret --secret-id "$n" \
> --region us-east-1 --force-delete-without-recovery
> done
> ```
> Force-delete only shells with **no value version** — a populated secret holds
> real operator material. (Hit on prod 2026-06-29; see PR #51's deploy failure.)
2. **IaC-managed SSM config — 8 params, real values owned in code:**
| Param | dev | prod |
@ -109,7 +156,7 @@ Three buckets:
| `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)* |
| `LLM_MODEL_ID` | `bedrock_converse:us.anthropic.claude-opus-4-8` | `bedrock_converse:us.anthropic.claude-opus-4-8` |
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

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { ACCOUNT, REGION } from "../lib/config";
import { ACCOUNT, REGION, bootstrapQualifier } 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";
@ -17,10 +17,18 @@ new OpenSweIamStack(app, "OpenSweIamStack", {
// The two env stacks — explicit kebab-case stackName (never let CDK default to
// PascalCase), env-parameterised so resources are `open-swe-<env>-*`.
//
// B-1 / OSWE-IAC-01: dev synthesizes against its OWN bootstrap qualifier
// (`oswedev`), so it deploys via the cdk-oswedev-* roles the dev infra role is
// scoped to — and NOT the default hnb659fds bootstrap roles that deploy prod.
// Prod stays on the default qualifier (no synthesizer override).
new OpenSweStack(app, "OpenSweDevStack", {
stackName: "open-swe-dev",
env,
envName: "dev",
synthesizer: new cdk.DefaultStackSynthesizer({
qualifier: bootstrapQualifier("dev"),
}),
});
new OpenSweStack(app, "OpenSweProdStack", {

View file

@ -28,16 +28,28 @@ export const prefix = (env: EnvName): string => `open-swe-${env}`;
* 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).
* Cross-env deploy isolation is enforced at the bootstrap layer too — see
* `bootstrapQualifier`: dev runs on its own qualifier so the dev infra role
* cannot assume the bootstrap roles that deploy prod.
*/
export const oidcSubject = (env: EnvName): string =>
env === "prod"
? `repo:${GITHUB_ORG}/${GITHUB_REPO}:environment:prod`
: `repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/dev`;
/**
* Per-env CDK bootstrap qualifier (B-1 / OSWE-IAC-01 fix). Dev runs on its OWN
* qualifier `oswedev` (bootstrapped into the `CDKToolkit-oswedev` stack), so the
* dev infra deploy role only assumes `cdk-oswedev-*` and can NO LONGER assume the
* default `cdk-hnb659fds-*` set whose admin `cfn-exec-role` deploys prod. Prod
* stays on the default qualifier. This closes the cross-env escalation where a
* dev-branch token could `cdk deploy open-swe-prod` via the shared bootstrap
* roles, bypassing prod's Environment approval gate.
*/
export const DEFAULT_BOOTSTRAP_QUALIFIER = "hnb659fds";
export const bootstrapQualifier = (env: EnvName): string =>
env === "dev" ? "oswedev" : DEFAULT_BOOTSTRAP_QUALIFIER;
/**
* The GitHub Actions OIDC provider already exists account-wide (created for
* seahaven-site; see .github/oidc-deploy-roles.yaml `CreateOIDCProvider=false`).

View file

@ -56,15 +56,12 @@ export const SECRET_VARS: readonly string[] = [
"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",
@ -73,7 +70,10 @@ export const SECRET_VARS: readonly string[] = [
"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
// 25 secret shells (OPENAI_API_KEY / GOOGLE_API_KEY / GROQ_API_KEY removed in the
// Bedrock/Fireworks migration — those providers are dropped from SUPPORTED_MODELS and
// their keys revoked + secret objects deleted). 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,
@ -93,15 +93,12 @@ const SECRET_DESCRIPTIONS: Record<string, string> = {
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.",
@ -142,8 +139,12 @@ export function iacManagedSsm(env: EnvName): Record<string, string> {
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",
// Primary builder model. seed_store.sh's `pick` precedence is
// OPENSWE_AGENT_MODEL > SEED_AGENT_MODEL > LLM_MODEL_ID > script default, so this
// SSM value overrides the seed-script default — it MUST be a supported id. Post
// Bedrock/Fireworks migration the only Bedrock-Claude id is the inference profile;
// `anthropic:claude-opus-4-8` was removed from SUPPORTED_MODELS.
LLM_MODEL_ID: "bedrock_converse:us.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).
@ -227,6 +228,23 @@ export class ConfigStore extends Construct {
// creates an empty secret, so the out-of-band value is never clobbered.
});
// RETAIN: a stack teardown must not destroy operator-set secret material.
//
// GOTCHA — RETAIN + fixed name orphans these shells on a FAILED FIRST
// CREATE. If the stack's initial create fails and rolls back, RETAIN keeps
// the shells instead of deleting them; the stack is then gone but the
// secrets survive, still holding the global `open-swe-<env>/<VAR>` names.
// Every later create then fails with `AlreadyExists` (and a normal
// delete-secret keeps the name reserved for the 7–30 day recovery window,
// so it does NOT clear the deadlock). Recovery: before re-creating the
// stack, force-delete the orphans so the names free immediately, e.g.
// aws secretsmanager list-secrets --filters Key=name,Values=open-swe-<env>/ \
// --query 'SecretList[].Name' --output text | tr '\t' '\n' | while read n; do
// aws secretsmanager delete-secret --secret-id "$n" \
// --force-delete-without-recovery; done
// Only force-delete shells that are EMPTY (no value version) — a populated
// secret holds real operator material. This bites on teardown/rebuild, a
// secret logical-id change/refactor, or standing up a new env — NOT on
// routine updates of an already-created stack. (Hit on prod 2026-06-29.)
secret.applyRemovalPolicy(cdk.RemovalPolicy.RETAIN);
this.secrets.push(secret);
}

View file

@ -5,6 +5,7 @@ import {
EnvName,
GITHUB_OIDC_PROVIDER_ARN,
REGION,
bootstrapQualifier,
oidcSubject,
} from "../config";
@ -62,20 +63,19 @@ export class GithubDeployRoles extends Construct {
// 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).
// B-1 / OSWE-IAC-01 fix: scope the assume to THIS env's bootstrap qualifier.
// Dev uses `oswedev` (its own CDKToolkit-oswedev bootstrap), prod uses the
// default `hnb659fds`. The dev infra role can therefore no longer assume the
// bootstrap roles whose admin cfn-exec-role deploys prod — closing the prior
// cross-env escalation (a dev-branch token could `cdk deploy open-swe-prod`
// via the shared account-wide bootstrap roles, bypassing prod's Environment
// approval gate). The qualifier wildcard still matches only the handful of
// roles `cdk bootstrap` creates for that qualifier.
this.infraRole.addToPolicy(
new iam.PolicyStatement({
sid: "AssumeCdkBootstrapRoles",
actions: ["sts:AssumeRole"],
resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-hnb659fds-*`],
resources: [`arn:aws:iam::${ACCOUNT}:role/cdk-${bootstrapQualifier(envName)}-*`],
}),
);
@ -150,10 +150,22 @@ export class GithubDeployRoles extends Construct {
resources: [`arn:aws:s3:::open-swe-${envName}-assets/releases/*`],
}),
);
// ListBucket is constrained to the releases/ prefix (F-1/IAC-04): the
// publish/rollback scripts only ever list under releases/, so a leaked CI
// token cannot enumerate anything else in the bucket. GetBucketLocation
// carries no s3:prefix, so it stays a separate, unconditioned statement.
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "ListArtifactBucket",
actions: ["s3:ListBucket", "s3:GetBucketLocation"],
actions: ["s3:ListBucket"],
resources: [`arn:aws:s3:::open-swe-${envName}-assets`],
conditions: { StringLike: { "s3:prefix": ["releases/*"] } },
}),
);
this.appRole.addToPolicy(
new iam.PolicyStatement({
sid: "GetArtifactBucketLocation",
actions: ["s3:GetBucketLocation"],
resources: [`arn:aws:s3:::open-swe-${envName}-assets`],
}),
);

View file

@ -43,10 +43,23 @@ export class InstanceRole extends Construct {
resources: [`arn:aws:s3:::${p}-assets/releases/*`],
}),
);
// ListBucket is constrained to the releases/ prefix (F-1/IAC-04) — the box
// only ever lists release artifacts, so a compromised box cannot enumerate
// any other object that might land in the bucket. GetBucketLocation has no
// s3:prefix in its request context, so it stays a separate, unconditioned
// statement (the condition would otherwise AccessDeny it).
this.role.addToPolicy(
new iam.PolicyStatement({
sid: "ListArtifactBucket",
actions: ["s3:ListBucket", "s3:GetBucketLocation"],
actions: ["s3:ListBucket"],
resources: [`arn:aws:s3:::${p}-assets`],
conditions: { StringLike: { "s3:prefix": ["releases/*"] } },
}),
);
this.role.addToPolicy(
new iam.PolicyStatement({
sid: "GetArtifactBucketLocation",
actions: ["s3:GetBucketLocation"],
resources: [`arn:aws:s3:::${p}-assets`],
}),
);
@ -85,6 +98,11 @@ export class InstanceRole extends Construct {
// 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.
// F-2 (accepted residual): because the grant is `*`, a caller naming a secret
// in ANOTHER env's prefix learns whether that name EXISTS (an existence oracle
// via the per-secret AccessDenied-vs-not signal) even though the VALUE stays
// gated by the prefix-scoped GetSecretValue above. Accepted within Sea Haven's
// single-tenant account 328440206208 — cross-env VALUE isolation is preserved.
this.role.addToPolicy(
new iam.PolicyStatement({
sid: "BatchGetSecretValues",
@ -99,6 +117,29 @@ export class InstanceRole extends Construct {
// moves these to a customer CMK, add a scoped `kms:Decrypt` on that key ARN
// ONLY (not `*`).
// Invoke the Bedrock Claude model. DEFAULT_MODEL_ID is
// `bedrock_converse:us.anthropic.claude-opus-4-8`, and the model runs in the
// LangGraph server PROCESS on this box (not in the sandbox), so the EC2
// instance role is the calling principal. The `us.` cross-region inference
// profile fans out to us-east-1 / us-east-2 / us-west-2, and Bedrock authorizes
// InvokeModel against BOTH the inference-profile ARN AND the underlying
// foundation-model ARN in each routed region — all four resources are required
// or the call AccessDenies. Scoped to opus-4-8 ONLY (least-privilege): adding a
// new Bedrock model to SUPPORTED_MODELS means extending this resource list.
// IAM change — flag for T4 (GPT-4.1 IAM cross-review) / T5 (/sh-security-review).
this.role.addToPolicy(
new iam.PolicyStatement({
sid: "InvokeBedrockClaude",
actions: ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
resources: [
`arn:aws:bedrock:${REGION}:${ACCOUNT}:inference-profile/us.anthropic.claude-opus-4-8`,
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-opus-4-8",
"arn:aws:bedrock:us-east-2::foundation-model/anthropic.claude-opus-4-8",
"arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-opus-4-8",
],
}),
);
// Ship application logs to CloudWatch Logs under /open-swe/<env>/*.
this.role.addToPolicy(
new iam.PolicyStatement({

View file

@ -0,0 +1,55 @@
import * as cdk from "aws-cdk-lib";
import { Match, Template } from "aws-cdk-lib/assertions";
import { OpenSweIamStack } from "../lib/open-swe-iam-stack";
import { bootstrapQualifier } from "../lib/config";
const ENV = { account: "328440206208", region: "us-east-1" };
// B-1 / OSWE-IAC-01: each env's infra deploy role may assume ONLY its own
// bootstrap qualifier's roles. Dev runs on `oswedev`, so a dev-branch token can
// no longer assume the default `hnb659fds` bootstrap roles whose admin
// cfn-exec-role deploys prod. Prod stays on the default qualifier.
describe("Per-env CDK bootstrap qualifier isolation (B-1/OSWE-IAC-01)", () => {
it("maps dev -> oswedev and prod -> hnb659fds", () => {
expect(bootstrapQualifier("dev")).toBe("oswedev");
expect(bootstrapQualifier("prod")).toBe("hnb659fds");
});
it("dev infra deploy role assumes only cdk-oswedev-* bootstrap roles", () => {
const app = new cdk.App();
const stack = new OpenSweIamStack(app, "OpenSweIamStack", {
stackName: "open-swe-iam",
env: ENV,
});
Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Sid: "AssumeCdkBootstrapRoles",
Action: "sts:AssumeRole",
Resource: "arn:aws:iam::328440206208:role/cdk-oswedev-*",
}),
]),
}),
});
});
it("prod infra deploy role stays on the default cdk-hnb659fds-* bootstrap roles", () => {
const app = new cdk.App();
const stack = new OpenSweIamStack(app, "OpenSweIamStack", {
stackName: "open-swe-iam",
env: ENV,
});
Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Sid: "AssumeCdkBootstrapRoles",
Action: "sts:AssumeRole",
Resource: "arn:aws:iam::328440206208:role/cdk-hnb659fds-*",
}),
]),
}),
});
});
});

View file

@ -0,0 +1,71 @@
import * as cdk from "aws-cdk-lib";
import { Match, Template } from "aws-cdk-lib/assertions";
import { OpenSweIamStack } from "../lib/open-swe-iam-stack";
import { OpenSweStack } from "../lib/open-swe-stack";
const ENV = { account: "328440206208", region: "us-east-1" };
// F-1 / IAC-04: s3:ListBucket must be constrained to the releases/ prefix so a
// compromised box / leaked CI token cannot enumerate the rest of the bucket.
const RELEASES_PREFIX_CONDITION = { StringLike: { "s3:prefix": ["releases/*"] } };
describe("S3 ListBucket prefix scoping (F-1/IAC-04)", () => {
it("instance role ListBucket is constrained to releases/*", () => {
const app = new cdk.App();
const stack = new OpenSweStack(app, "OpenSweDevStack", {
stackName: "open-swe-dev",
env: ENV,
envName: "dev",
});
Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Sid: "ListArtifactBucket",
Action: "s3:ListBucket",
Condition: RELEASES_PREFIX_CONDITION,
}),
]),
}),
});
});
it("github deploy app role ListBucket is constrained to releases/*", () => {
const app = new cdk.App();
const stack = new OpenSweIamStack(app, "OpenSweIamStack", {
stackName: "open-swe-iam",
env: ENV,
});
Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Sid: "ListArtifactBucket",
Action: "s3:ListBucket",
Condition: RELEASES_PREFIX_CONDITION,
}),
]),
}),
});
});
it("GetBucketLocation stays a separate, unconditioned statement", () => {
const app = new cdk.App();
const stack = new OpenSweStack(app, "OpenSweDevStack", {
stackName: "open-swe-dev",
env: ENV,
envName: "dev",
});
Template.fromStack(stack).hasResourceProperties("AWS::IAM::Policy", {
PolicyDocument: Match.objectLike({
Statement: Match.arrayWith([
Match.objectLike({
Sid: "GetArtifactBucketLocation",
Action: "s3:GetBucketLocation",
Condition: Match.absent(),
}),
]),
}),
});
});
});

View file

@ -7,7 +7,7 @@ requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"deepagents==0.6.12",
"fastapi>=0.138.1",
"fastapi>=0.138.2",
"uvicorn>=0.49.0",
"httpx>=0.28.1",
"PyJWT>=2.13.0",
@ -17,6 +17,7 @@ dependencies = [
"langgraph>=1.1.10",
"markdownify>=1.2.2",
"langchain-anthropic>=1.4.6",
"langchain-aws>=0.2.0",
"langgraph-cli[inmem]>=0.4.30",
"langsmith==0.9.3",
"langchain-openai>=1.3.3",

View file

@ -50,15 +50,18 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
patch(
"agent.server.get_team_default_model_pair",
new_callable=AsyncMock,
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
return_value=(
("bedrock_converse:us.anthropic.claude-opus-4-8", "medium"),
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "low"),
),
),
patch(
"agent.server.load_profile",
new_callable=AsyncMock,
return_value={
"default_model": "anthropic:claude-opus-4-8",
"default_model": "bedrock_converse:us.anthropic.claude-opus-4-8",
"reasoning_effort": "high",
"default_subagent_model": "openai:gpt-5.5",
"default_subagent_model": "fireworks:accounts/fireworks/models/deepseek-v4-pro",
"subagent_reasoning_effort": "xhigh",
},
),
@ -76,13 +79,15 @@ async def test_agent_uses_profile_subagent_model_override() -> None:
assert subagents[0]["model"] is subagent_model
main_call = make_model.call_args_list[0]
assert main_call.args == ("anthropic:claude-opus-4-8",)
assert main_call.kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert main_call.kwargs["effort"] == "high"
assert main_call.args == ("bedrock_converse:us.anthropic.claude-opus-4-8",)
assert main_call.kwargs["additional_model_request_fields"] == {
"thinking": {"type": "adaptive", "display": "summarized"},
"output_config": {"effort": "high"},
}
subagent_call = make_model.call_args_list[1]
assert subagent_call.args == ("openai:gpt-5.5",)
assert subagent_call.kwargs["reasoning"] == {"effort": "xhigh", "summary": "auto"}
assert subagent_call.args == ("fireworks:accounts/fireworks/models/deepseek-v4-pro",)
assert subagent_call.kwargs["model_kwargs"] == {"reasoning_effort": "xhigh"}
@pytest.mark.asyncio
@ -123,13 +128,16 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
patch(
"agent.server.get_team_default_model_pair",
new_callable=AsyncMock,
return_value=(("openai:gpt-5.5", "medium"), ("openai:gpt-5.5", "low")),
return_value=(
("bedrock_converse:us.anthropic.claude-opus-4-8", "medium"),
("fireworks:accounts/fireworks/models/deepseek-v4-pro", "low"),
),
),
patch(
"agent.server.load_profile",
new_callable=AsyncMock,
return_value={
"default_model": "anthropic:claude-opus-4-8",
"default_model": "bedrock_converse:us.anthropic.claude-opus-4-8",
"reasoning_effort": "high",
},
),
@ -143,10 +151,9 @@ async def test_agent_subagent_inherits_profile_model_override_without_explicit_p
subagents = captured["subagents"]
assert isinstance(subagents, list)
assert subagents[0]["model"] is subagent_model
assert make_model.call_args_list[0].args == ("anthropic:claude-opus-4-8",)
assert make_model.call_args_list[1].args == ("anthropic:claude-opus-4-8",)
assert make_model.call_args_list[1].kwargs["thinking"] == {
"type": "adaptive",
"display": "summarized",
assert make_model.call_args_list[0].args == ("bedrock_converse:us.anthropic.claude-opus-4-8",)
assert make_model.call_args_list[1].args == ("bedrock_converse:us.anthropic.claude-opus-4-8",)
assert make_model.call_args_list[1].kwargs["additional_model_request_fields"] == {
"thinking": {"type": "adaptive", "display": "summarized"},
"output_config": {"effort": "high"},
}
assert make_model.call_args_list[1].kwargs["effort"] == "high"

View file

@ -0,0 +1,34 @@
from __future__ import annotations
from agent.dashboard.agent_overrides import profile_author_prs_as_user
from agent.utils.authorship import OPEN_SWE_BOT_EMAIL, OPEN_SWE_BOT_NAME
from agent.utils.github_org_membership import INTERNAL_BOT_LOGINS
def test_author_prs_as_user_defaults_off() -> None:
# Default is the app bot; the per-user opt-in must be explicit.
assert profile_author_prs_as_user(None) is False
assert profile_author_prs_as_user({}) is False
assert profile_author_prs_as_user({"author_prs_as_user": "true"}) is False
assert profile_author_prs_as_user({"author_prs_as_user": True}) is True
def test_commit_identity_is_the_app_bot() -> None:
assert OPEN_SWE_BOT_NAME == "seahaven-openswe[bot]"
assert OPEN_SWE_BOT_EMAIL == "296972425+seahaven-openswe[bot]@users.noreply.github.com"
def test_self_trigger_guard_recognizes_our_app_bot() -> None:
# Bot-authored PRs/actions must be recognized as our own to avoid self-trigger loops.
assert "seahaven-openswe[bot]" in INTERNAL_BOT_LOGINS
def test_default_commit_identity_in_prompt_is_the_app_bot() -> None:
# SH-IDSPLIT-01: with no triggering-user identity (the default-bot path), the
# constructed prompt must set the git commit identity to the app bot — so
# commits, push, and PR all come in as the app (not the triggering user).
from agent.prompt import construct_system_prompt
prompt = construct_system_prompt(working_dir="/workspace", triggering_user_identity=None)
assert OPEN_SWE_BOT_NAME in prompt
assert OPEN_SWE_BOT_EMAIL in prompt

View file

@ -0,0 +1,81 @@
"""AUTH-RESP-LEAK-01: upstream auth-error bodies must not reach user-facing text."""
from __future__ import annotations
import asyncio
from typing import Any
import httpx
import pytest
from agent.utils import auth
_SECRET_BODY = "SENSITIVE-UPSTREAM-BODY-9999"
class _Resp:
def __init__(self, status_code: int, text: str) -> None:
self.status_code = status_code
self.text = text
def raise_for_status(self) -> None:
raise httpx.HTTPStatusError(
"boom",
request=httpx.Request("POST", "http://example.test"),
response=self, # type: ignore[arg-type]
)
def json(self) -> Any:
return {}
class _Client:
def __init__(self, resp: _Resp) -> None:
self._resp = resp
async def __aenter__(self) -> _Client:
return self
async def __aexit__(self, *_a: Any) -> None:
return None
async def post(self, *_a: Any, **_k: Any) -> _Resp:
return self._resp
class _OkResp:
"""A 2xx response whose JSON body lacks both a token and an auth url."""
def __init__(self, payload: Any) -> None:
self._payload = payload
def raise_for_status(self) -> None:
return None
def json(self) -> Any:
return self._payload
def test_http_error_returns_generic_message(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(auth, "GITHUB_OAUTH_PROVIDER_ID", "provider-id")
monkeypatch.setattr(auth, "X_SERVICE_AUTH_JWT_SECRET", "jwt-secret")
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: _Client(_Resp(500, _SECRET_BODY)))
result = asyncio.run(auth.get_github_token_for_user("ls-user", "tenant"))
assert "error" in result
assert _SECRET_BODY not in result["error"]
assert "500" in result["error"]
def test_unexpected_result_returns_generic_message(monkeypatch: pytest.MonkeyPatch) -> None:
"""The 2xx-but-missing-token/url branch must not echo the upstream body."""
monkeypatch.setattr(auth, "GITHUB_OAUTH_PROVIDER_ID", "provider-id")
monkeypatch.setattr(auth, "X_SERVICE_AUTH_JWT_SECRET", "jwt-secret")
body = {"unexpected_field": _SECRET_BODY}
monkeypatch.setattr(httpx, "AsyncClient", lambda *a, **k: _Client(_OkResp(body)))
result = asyncio.run(auth.get_github_token_for_user("ls-user", "tenant"))
assert result == {"error": "GitHub auth returned an unexpected result"}
assert _SECRET_BODY not in result["error"]

View file

@ -78,10 +78,42 @@ def _stub_dashboard_store(
monkeypatch.setattr(profiles, "_get_value", fake_get_value)
def test_resolve_github_token_slack_uses_dashboard_store(
def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None:
"""Mock the per-user profile lookup that gates per-user attribution.
``author_prs_as_user=False`` → no opt-in (default: author as the app bot).
"""
from agent.dashboard import agent_overrides
profile = {"author_prs_as_user": True} if author_prs_as_user else None
async def fake_load_profile(login: str):
return profile
monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile)
def test_resolve_github_token_slack_defaults_to_bot(monkeypatch: pytest.MonkeyPatch) -> None:
# DEFAULT (no author_prs_as_user opt-in): slack runs author as the app bot,
# even when a valid per-user token exists — so PRs come in as the app.
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=False)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
async def fake_bot(thread_id: str):
return ("bot-tok", None)
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fake_bot)
token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
assert token == "bot-tok"
def test_resolve_github_token_slack_optin_uses_dashboard_store(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, expires_at = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
@ -90,7 +122,7 @@ def test_resolve_github_token_slack_uses_dashboard_store(
assert expires_at == "2099-01-01T00:00:00Z"
def test_resolve_github_token_slack_ignores_stale_thread_cache(
def test_resolve_github_token_slack_optin_ignores_stale_thread_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Slack thread ids are shared, so a prior user's cached token must NOT be
@ -100,6 +132,7 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache(
token="bob-token",
cached=("alice-token", "2099-01-01T00:00:00Z"),
)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
token, _ = asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
@ -107,24 +140,26 @@ def test_resolve_github_token_slack_ignores_stale_thread_cache(
assert token == "bob-token"
def test_resolve_github_token_slack_no_token_raises(
def test_resolve_github_token_slack_optin_no_token_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: False)
with pytest.raises(auth.GitHubUserAuthRequired):
asyncio.run(auth.resolve_github_token(_slack_config(), "t1"))
def test_resolve_github_token_per_user_wins_over_bot_only_mode(
def test_resolve_github_token_optin_per_user_wins_over_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token="user-tok")
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fail_bot(thread_id: str):
raise AssertionError("bot token must not be used when a user token exists")
raise AssertionError("bot token must not be used when the opt-in user token exists")
monkeypatch.setattr(auth, "_resolve_bot_installation_token", fail_bot)
@ -132,10 +167,11 @@ def test_resolve_github_token_per_user_wins_over_bot_only_mode(
assert token == "user-tok"
def test_resolve_github_token_slack_no_token_falls_back_to_bot_in_bot_only_mode(
def test_resolve_github_token_slack_optin_no_token_falls_back_to_bot_in_bot_only_mode(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_stub_dashboard_store(monkeypatch, token=None)
_set_profile(monkeypatch, author_prs_as_user=True)
monkeypatch.setattr(auth, "is_bot_token_only_mode", lambda: True)
async def fake_bot(thread_id: str):

View file

@ -8,7 +8,7 @@ from agent.dashboard.agent_overrides import resolve_agent_model_id
from agent.dashboard.options import model_supports_images
_TEXT_ONLY_MODEL = "fireworks:accounts/fireworks/models/deepseek-v4-pro"
_VISION_MODEL = "openai:gpt-5.5"
_VISION_MODEL = "bedrock_converse:us.anthropic.claude-opus-4-8"
def _image() -> thread_api.DashboardImageBody:
@ -73,11 +73,11 @@ async def test_resolve_agent_model_choice_applies_request_before_profile(monkeyp
model_id, effort = await thread_api._resolve_agent_model_choice(
{"default_model": _TEXT_ONLY_MODEL, "reasoning_effort": "high"},
"anthropic:claude-opus-4-8",
"bedrock_converse:us.anthropic.claude-opus-4-8",
"high",
)
assert (model_id, effort) == ("anthropic:claude-opus-4-8", "high")
assert (model_id, effort) == ("bedrock_converse:us.anthropic.claude-opus-4-8", "high")
async def test_resolve_agent_model_id_defaults_to_team_default(monkeypatch) -> None:
@ -113,8 +113,10 @@ async def test_resolve_agent_model_id_applies_per_thread_override(monkeypatch) -
monkeypatch.setattr("agent.dashboard.agent_overrides.get_team_default_model", fake_team_default)
monkeypatch.setattr("agent.dashboard.agent_overrides.load_profile", lambda login: None)
model_id = await resolve_agent_model_id(None, per_thread_model_id="anthropic:claude-opus-4-8")
assert model_id == "anthropic:claude-opus-4-8"
model_id = await resolve_agent_model_id(
None, per_thread_model_id="bedrock_converse:us.anthropic.claude-opus-4-8"
)
assert model_id == "bedrock_converse:us.anthropic.claude-opus-4-8"
def _new_thread_client(created: dict[str, object]) -> object:

View file

@ -169,7 +169,7 @@ async def test_dashboard_followup_on_busy_thread_queues_images(
metadata = {
"source": "dashboard",
"github_login": "octocat",
"resolved_model": "openai:gpt-5.5",
"resolved_model": "bedrock_converse:us.anthropic.claude-opus-4-8",
}
client = _FakeClient(metadata)
queued_messages: list[object] = []
@ -232,7 +232,7 @@ async def test_dashboard_followup_on_busy_text_only_thread_rejects_images(
thread_api.ThreadMessageBody(
content="continue in web",
images=[thread_api.DashboardImageBody(base64="aW1hZ2U=", mimeType="image/png")],
model_id="openai:gpt-5.5",
model_id="fireworks:accounts/fireworks/models/deepseek-v4-pro",
effort="medium",
),
)

View file

@ -56,8 +56,11 @@ def test_provider_model_kwargs_for_fireworks_unknown_effort_omits_reasoning() ->
assert "model_kwargs" not in kwargs
def test_fireworks_has_no_cross_provider_fallback() -> None:
assert fallback_model_id_for("fireworks:accounts/fireworks/models/deepseek-v4-pro") is None
def test_fireworks_falls_back_to_bedrock() -> None:
assert (
fallback_model_id_for("fireworks:accounts/fireworks/models/deepseek-v4-pro")
== "bedrock_converse:us.anthropic.claude-opus-4-8"
)
@pytest.mark.parametrize(

View file

@ -1127,7 +1127,9 @@ def test_process_github_pr_comment_without_email_skips(
def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch) -> None:
captured: dict[str, object] = {}
async def fake_get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
async def fake_get_or_resolve_thread_github_token(
thread_id: str, email: str, *, repo: dict[str, str] | None = None
) -> str | None:
captured["thread_id"] = thread_id
captured["email"] = email
return "user-token"
@ -1210,7 +1212,9 @@ def test_process_github_issue_uses_resolved_user_token_for_reaction(monkeypatch)
def test_process_github_issue_existing_thread_uses_followup_prompt(monkeypatch) -> None:
captured: dict[str, object] = {}
async def fake_get_or_resolve_thread_github_token(thread_id: str, email: str) -> str | None:
async def fake_get_or_resolve_thread_github_token(
thread_id: str, email: str, *, repo: dict[str, str] | None = None
) -> str | None:
return "user-token"
async def fake_get_github_app_installation_token() -> str | None:

View file

@ -69,7 +69,7 @@ def test_cached_token_expires_after_max_ttl() -> None:
"""A token with no/far expiry is still dropped once it's older than the 24h cap."""
far_future = (datetime.now(UTC) + timedelta(days=30)).isoformat()
old_cached_at = datetime.now(UTC) - timedelta(hours=25)
github_token._GITHUB_TOKEN_CACHE["tid"] = ("ghp_secret", far_future, old_cached_at)
github_token._GITHUB_TOKEN_CACHE["tid"] = ("ghp_secret", far_future, old_cached_at, None)
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) is None
@ -210,7 +210,7 @@ def test_process_github_pr_comment_invalidates_and_reauths_on_401(
tokens = iter(["stale-token", "fresh-token"])
async def fake_get_or_resolve(thread_id: str, email: str) -> str | None:
async def fake_get_or_resolve(thread_id: str, email: str, *, repo: Any = None) -> str | None:
token = next(tokens)
resolves.append(token)
return token

View file

@ -1,50 +0,0 @@
from agent.dashboard.options import SUPPORTED_MODELS, provider_fallback_pair
from agent.utils.model import (
google_thinking_level_for,
is_gemini_3_family,
provider_model_kwargs,
)
def test_gemini_3_family_detection() -> None:
assert is_gemini_3_family("google_genai:gemini-3.5-flash") is True
assert is_gemini_3_family("google_genai:gemini-2.5-flash") is False
def test_google_thinking_level_maps_effort() -> None:
assert google_thinking_level_for("minimal") == "minimal"
assert google_thinking_level_for("none") == "minimal"
assert google_thinking_level_for("medium") == "medium"
assert google_thinking_level_for("high") == "high"
assert google_thinking_level_for("unknown") is None
def test_gemini_35_flash_is_supported_with_documented_efforts() -> None:
gemini = next(m for m in SUPPORTED_MODELS if m["id"] == "google_genai:gemini-3.5-flash")
assert gemini["label"] == "Gemini 3.5 Flash"
assert gemini["efforts"] == ["minimal", "low", "medium", "high"]
assert gemini["default_effort"] == "medium"
def test_google_provider_fallback_uses_gemini_35_flash() -> None:
assert provider_fallback_pair("google_genai:gemini-3-flash-preview", "high") == (
"google_genai:gemini-3.5-flash",
"high",
)
def test_google_provider_fallback_maps_legacy_none_to_minimal() -> None:
assert provider_fallback_pair("google_genai:gemini-3-flash-preview", "none") == (
"google_genai:gemini-3.5-flash",
"minimal",
)
def test_provider_model_kwargs_for_google() -> None:
kwargs = provider_model_kwargs(
"google_genai:gemini-3.5-flash",
"high",
max_tokens=16_000,
)
assert kwargs["max_tokens"] == 16_000
assert kwargs["thinking_level"] == "high"

View file

@ -0,0 +1,53 @@
"""Replay-window enforcement for Linear webhook signature verification (AUTHZ-001)."""
from __future__ import annotations
import hashlib
import hmac
import json
from datetime import UTC, datetime
from agent import webapp
_SECRET = "linear-signing-secret"
def _sign(body: bytes) -> str:
return hmac.new(_SECRET.encode("utf-8"), body, hashlib.sha256).hexdigest()
def _now_ms() -> int:
return int(datetime.now(UTC).timestamp() * 1000)
def test_fresh_timestamp_accepted() -> None:
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is True
def test_stale_timestamp_rejected() -> None:
stale = _now_ms() - 10 * 60 * 1000 # 10 minutes old
body = json.dumps({"type": "Comment", "webhookTimestamp": stale}).encode()
# Signature is valid, but the timestamp is outside the freshness window.
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
def test_future_timestamp_rejected() -> None:
future = _now_ms() + 10 * 60 * 1000
body = json.dumps({"type": "Comment", "webhookTimestamp": future}).encode()
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
def test_missing_timestamp_rejected() -> None:
body = json.dumps({"type": "Comment"}).encode()
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
def test_non_numeric_timestamp_rejected() -> None:
body = json.dumps({"type": "Comment", "webhookTimestamp": "not-a-number"}).encode()
assert webapp.verify_linear_signature(body, _sign(body), _SECRET) is False
def test_bad_signature_rejected_even_when_fresh() -> None:
body = json.dumps({"type": "Comment", "webhookTimestamp": _now_ms()}).encode()
assert webapp.verify_linear_signature(body, "deadbeef", _SECRET) is False

View file

@ -10,8 +10,8 @@ from agent.dashboard.options import (
)
from agent.dashboard.team_settings import get_team_default_model
STALE_ANTHROPIC = "anthropic:claude-opus-4-7"
SUPPORTED_ANTHROPIC = "anthropic:claude-opus-4-8"
STALE_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-7"
SUPPORTED_ANTHROPIC = "bedrock_converse:us.anthropic.claude-opus-4-8"
def test_provider_fallback_preserves_provider_and_effort() -> None:
@ -23,9 +23,9 @@ def test_provider_fallback_uses_default_effort_when_unsupported() -> None:
assert provider_fallback_pair(STALE_ANTHROPIC, None) == (SUPPORTED_ANTHROPIC, "high")
def test_provider_fallback_resolves_openai_within_provider() -> None:
model, effort = provider_fallback_pair("openai:gpt-5-legacy", "low")
assert model.startswith("openai:")
def test_provider_fallback_resolves_fireworks_within_provider() -> None:
model, effort = provider_fallback_pair("fireworks:accounts/fireworks/models/legacy-old", "low")
assert model.startswith("fireworks:")
assert effort == "low"

View file

@ -11,6 +11,24 @@ import agent.tools.open_pull_request # noqa: F401
opr = sys.modules["agent.tools.open_pull_request"]
def _set_profile(monkeypatch: pytest.MonkeyPatch, *, author_prs_as_user: bool) -> None:
"""Mock the per-user profile lookup that gates per-user PR attribution."""
from agent.dashboard import agent_overrides
profile = {"author_prs_as_user": True} if author_prs_as_user else None
async def fake_load_profile(login: str):
return profile
monkeypatch.setattr(agent_overrides, "load_profile", fake_load_profile)
@pytest.fixture(autouse=True)
def _default_no_optin_profile(monkeypatch: pytest.MonkeyPatch) -> None:
# Default: the author_prs_as_user opt-in is OFF, so PRs author as the app bot.
_set_profile(monkeypatch, author_prs_as_user=False)
class _FakeResponse:
def __init__(self, status_code: int, payload: Any = None, text: str = "") -> None:
self.status_code = status_code
@ -101,11 +119,46 @@ def _open() -> dict[str, Any]:
)
def test_uses_user_token_for_slack_with_login(monkeypatch: pytest.MonkeyPatch) -> None:
def test_defaults_to_bot_for_slack_without_optin(monkeypatch: pytest.MonkeyPatch) -> None:
# DEFAULT (no author_prs_as_user opt-in): slack PRs are opened as the app bot,
# even when a valid per-user token exists — so the PR is attributed to the app.
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
from agent.dashboard import profiles
async def fail_user_token(login: str, **_kw: Any) -> str | None:
raise AssertionError("user token must not be used without the author_prs_as_user opt-in")
monkeypatch.setattr(profiles, "get_valid_access_token", fail_user_token)
async def fake_bot() -> str | None:
return "bot-tok"
monkeypatch.setattr(opr, "get_github_app_installation_token", fake_bot)
client = _FakeClient(
post=_FakeResponse(
201,
{
"html_url": "https://x/pull/1",
"number": 1,
"user": {"login": "seahaven-openswe[bot]"},
},
)
)
_install_client(monkeypatch, client)
result = _open()
assert result["token_kind"] == "bot"
assert client.post_calls[0]["headers"]["Authorization"] == "Bearer bot-tok"
def test_uses_user_token_for_slack_with_optin(monkeypatch: pytest.MonkeyPatch) -> None:
_set_config(monkeypatch, {"source": "slack", "github_login": "johannes117"})
_set_profile(monkeypatch, author_prs_as_user=True)
from agent.dashboard import profiles
async def fake_user_token(login: str, **_kw: Any) -> str | None:
assert login == "johannes117"
return "user-tok"

View file

@ -264,6 +264,7 @@ class TestRefreshProxyOnSandboxReuse:
mock_sandbox = MagicMock(id="sandbox-cached")
with (
patch("agent.server.client.threads.update", new_callable=AsyncMock),
patch(
"agent.server.resolve_github_token",
new_callable=AsyncMock,
@ -308,11 +309,12 @@ class TestRefreshProxyOnSandboxReuse:
@pytest.mark.asyncio
async def test_refreshes_proxy_when_reconnecting_to_existing_langsmith_sandbox(self) -> None:
"""Reconnected sandboxes should also get a fresh proxy token."""
"""A bound thread reconnecting to its sandbox should get a fresh proxy token."""
config = self._execution_config()
mock_sandbox = MagicMock(id="sandbox-existing")
with (
patch("agent.server.client.threads.update", new_callable=AsyncMock),
patch(
"agent.server.resolve_github_token",
new_callable=AsyncMock,
@ -323,6 +325,13 @@ class TestRefreshProxyOnSandboxReuse:
new_callable=AsyncMock,
return_value="sandbox-existing",
),
# Thread is bound to the current repo, so reconnect proceeds (an unbound
# legacy thread would instead fail closed and recreate).
patch(
"agent.server.get_bound_repo_from_metadata",
new_callable=AsyncMock,
return_value="langchain-ai/open-swe",
),
patch("agent.server.create_sandbox", return_value=mock_sandbox) as mock_create,
patch(
"agent.server.get_github_app_installation_token_with_expiry",

View file

@ -0,0 +1,225 @@
"""Repo-binding isolation for the sandbox and GitHub-token caches.
Covers TID-COLLIDE-01: a second repo presenting a colliding thread_id must not
reuse the first repo's in-process sandbox or cached GitHub token.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import pytest
from agent import server
from agent.utils import github_token
@pytest.fixture(autouse=True)
def _clear_caches() -> None:
github_token._GITHUB_TOKEN_CACHE.clear()
server.SANDBOX_BACKENDS.clear()
# --- token cache repo binding ------------------------------------------------
def test_cached_token_not_reused_across_repos() -> None:
"""A token bound to repo A is refused for a colliding thread_id from repo B."""
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
github_token.cache_github_token_for_thread(
"tid", "ghp_repoA", expires_at=future, repo={"owner": "acme", "name": "alpha"}
)
# Same thread_id, different repo → must NOT serve repo A's token.
cfg_b = {"configurable": {"thread_id": "tid", "repo": {"owner": "evil", "name": "beta"}}}
assert github_token.get_github_token(cfg_b) is None
# And the poisoned entry is evicted.
assert "tid" not in github_token._GITHUB_TOKEN_CACHE
def test_cached_token_reused_for_same_repo() -> None:
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
github_token.cache_github_token_for_thread(
"tid", "ghp_repoA", expires_at=future, repo={"owner": "acme", "name": "alpha"}
)
cfg_a = {"configurable": {"thread_id": "tid", "repo": {"owner": "acme", "name": "alpha"}}}
assert github_token.get_github_token(cfg_a) == "ghp_repoA"
def test_unbound_token_served_when_repo_unknown() -> None:
"""Legacy entries with no bound repo still resolve when no repo is supplied."""
github_token.cache_github_token_for_thread("tid", "ghp_legacy")
assert github_token.get_github_token({"configurable": {"thread_id": "tid"}}) == "ghp_legacy"
def test_cached_token_reused_for_same_repo_case_insensitive() -> None:
"""``Org/Repo`` and ``org/repo`` are the same repo: no spurious refusal."""
future = (datetime.now(UTC) + timedelta(hours=1)).isoformat()
github_token.cache_github_token_for_thread(
"tid", "ghp_repoA", expires_at=future, repo={"owner": "Acme", "name": "Alpha"}
)
cfg = {"configurable": {"thread_id": "tid", "repo": {"owner": "acme", "name": "alpha"}}}
assert github_token.get_github_token(cfg) == "ghp_repoA"
def test_repo_cache_key_normalizes() -> None:
assert github_token.repo_cache_key({"owner": "o", "name": "r"}) == "o/r"
assert github_token.repo_cache_key("o/r") == "o/r"
assert github_token.repo_cache_key({"owner": "o"}) is None
assert github_token.repo_cache_key(None) is None
# Casefolded so different casing of the same repo collapses to one key.
assert github_token.repo_cache_key({"owner": "Org", "name": "Repo"}) == "org/repo"
assert github_token.repo_cache_key("Org/Repo") == "org/repo"
# --- sandbox repo binding ----------------------------------------------------
class _FakeBackend:
def __init__(self, sandbox_id: str) -> None:
self.id = sandbox_id
self.bound_repo: str | None = None
def execute(self, *_a: Any, **_k: Any) -> Any: # pragma: no cover - must not run
raise AssertionError("colliding-repo sandbox must not be pinged/reused")
@pytest.mark.asyncio
async def test_ensure_sandbox_refuses_colliding_repo(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_sandbox_id(_tid: str) -> str:
return "sb-A"
async def fake_bound_repo(_tid: str) -> str:
return "acme/alpha"
monkeypatch.setattr(server, "get_sandbox_id_from_metadata", fake_sandbox_id)
monkeypatch.setattr(server, "get_bound_repo_from_metadata", fake_bound_repo)
backend = _FakeBackend("sb-A")
backend.bound_repo = "acme/alpha"
server.SANDBOX_BACKENDS["tid"] = backend # type: ignore[assignment]
with pytest.raises(server.SandboxRepoMismatchError):
await server.ensure_sandbox_for_thread("tid", repo={"owner": "evil", "name": "beta"})
@pytest.mark.asyncio
async def test_ensure_sandbox_allows_matching_repo(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_sandbox_id(_tid: str) -> str:
return "sb-A"
async def fake_bound_repo(_tid: str) -> str:
return "acme/alpha"
calls: dict[str, int] = {"git": 0}
backend = _FakeBackend("sb-A")
backend.bound_repo = "acme/alpha"
async def fake_check(b: Any, *_a: Any, **_k: Any) -> Any:
return b
async def fake_refresh(b: Any, *_a: Any, **_k: Any) -> Any:
return b
async def fake_git(_b: Any) -> None:
calls["git"] += 1
monkeypatch.setattr(server, "get_sandbox_id_from_metadata", fake_sandbox_id)
monkeypatch.setattr(server, "get_bound_repo_from_metadata", fake_bound_repo)
monkeypatch.setattr(server, "check_or_recreate_sandbox", fake_check)
monkeypatch.setattr(server, "_refresh_github_proxy_or_recreate", fake_refresh)
monkeypatch.setattr(server, "set_sandbox_backend", lambda _tid, b, **_k: b)
monkeypatch.setattr(server, "_configure_git_identity", fake_git)
server.SANDBOX_BACKENDS["tid"] = backend # type: ignore[assignment]
result = await server.ensure_sandbox_for_thread("tid", repo={"owner": "acme", "name": "alpha"})
assert result is backend
assert calls["git"] == 1
@pytest.mark.asyncio
async def test_ensure_sandbox_reuses_same_repo_case_insensitive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A bound sandbox is reused when the current repo differs only by casing."""
async def fake_sandbox_id(_tid: str) -> str:
return "sb-A"
async def fake_bound_repo(_tid: str) -> str:
return "acme/alpha"
backend = _FakeBackend("sb-A")
backend.bound_repo = "acme/alpha"
async def fake_check(b: Any, *_a: Any, **_k: Any) -> Any:
return b
async def fake_refresh(b: Any, *_a: Any, **_k: Any) -> Any:
return b
async def fake_git(_b: Any) -> None:
return None
monkeypatch.setattr(server, "get_sandbox_id_from_metadata", fake_sandbox_id)
monkeypatch.setattr(server, "get_bound_repo_from_metadata", fake_bound_repo)
monkeypatch.setattr(server, "check_or_recreate_sandbox", fake_check)
monkeypatch.setattr(server, "_refresh_github_proxy_or_recreate", fake_refresh)
monkeypatch.setattr(server, "set_sandbox_backend", lambda _tid, b, **_k: b)
monkeypatch.setattr(server, "_configure_git_identity", fake_git)
server.SANDBOX_BACKENDS["tid"] = backend # type: ignore[assignment]
# Different casing of the same repo must not raise and must reuse the sandbox.
result = await server.ensure_sandbox_for_thread("tid", repo={"owner": "ACME", "name": "Alpha"})
assert result is backend
@pytest.mark.asyncio
async def test_legacy_unbound_sandbox_recreated_not_reused(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A legacy thread (sandbox_id present, bound_repo absent) must fail closed.
With no recorded binding, the existing sandbox cannot be confirmed to belong
to the current repo, so it is never reconnected-and-served: a fresh sandbox is
created and bound to the requesting repo instead.
"""
async def fake_sandbox_id(_tid: str) -> str:
return "sb-legacy"
async def fake_bound_repo(_tid: str) -> None:
return None
def fake_reconnect(*_a: Any, **_k: Any) -> Any: # pragma: no cover - must not run
raise AssertionError("must not reconnect to a legacy unbound sandbox")
fresh = _FakeBackend("sb-fresh")
async def fake_create_with_proxy(*_a: Any, **_k: Any) -> Any:
return fresh
async def fake_git(_b: Any) -> None:
return None
class _Threads:
async def update(self, **_k: Any) -> None:
return None
async def get(self, *_a: Any, **_k: Any) -> dict[str, Any]:
return {}
class _Client:
threads = _Threads()
monkeypatch.setattr(server, "get_sandbox_id_from_metadata", fake_sandbox_id)
monkeypatch.setattr(server, "get_bound_repo_from_metadata", fake_bound_repo)
monkeypatch.setattr(server, "create_sandbox", fake_reconnect)
monkeypatch.setattr(server, "_create_sandbox_with_proxy", fake_create_with_proxy)
monkeypatch.setattr(server, "_configure_git_identity", fake_git)
monkeypatch.setattr(server, "client", _Client())
result = await server.ensure_sandbox_for_thread("tid", repo={"owner": "evil", "name": "beta"})
assert result.id == "sb-fresh"
assert server.SANDBOX_BACKENDS["tid"].bound_repo == "evil/beta"

View file

@ -221,7 +221,9 @@ async def test_reviewer_resolves_app_installation_token_at_run_start() -> None:
# Token is resolved in this process at run start (scoped to the repo), not read
# from a cache the webhook handler populated in a different process.
mock_app_token.assert_awaited_once_with(repositories=["repo"])
mock_cache_token.assert_called_once_with("reviewer-thread-id", "app-token", expires_at=None)
mock_cache_token.assert_called_once_with(
"reviewer-thread-id", "app-token", expires_at=None, repo={"owner": "acme", "name": "repo"}
)
middleware = create_agent.call_args.kwargs["middleware"]
assert reviewer.check_message_queue_before_model in middleware

View file

@ -4,6 +4,7 @@ from unittest.mock import MagicMock
import pytest
from langchain_anthropic import ChatAnthropic
from langchain_aws import ChatBedrockConverse
from langchain_core.messages import AIMessage, HumanMessage
from agent.middleware.sanitize_thinking_blocks import SanitizeThinkingBlocksMiddleware
@ -66,6 +67,32 @@ class TestSanitizeThinkingBlocksMiddleware:
assert result is response
assert message.content == [{"type": "text", "text": "ok"}]
def test_drops_empty_reasoning_content_block_for_bedrock(self) -> None:
message = AIMessage(
content=[
{"type": "reasoning_content", "reasoning_content": {"text": "", "signature": ""}},
{"type": "text", "text": "ok"},
]
)
request = _make_request([message], model=MagicMock(spec=ChatBedrockConverse))
SanitizeThinkingBlocksMiddleware().wrap_model_call(request, lambda req: MagicMock())
assert message.content == [{"type": "text", "text": "ok"}]
def test_preserves_non_empty_reasoning_content_block_for_bedrock(self) -> None:
reasoning_block = {
"type": "reasoning_content",
"reasoning_content": {"text": "because", "signature": "sig"},
}
text_block = {"type": "text", "text": "ok"}
message = AIMessage(content=[reasoning_block, text_block])
request = _make_request([message], model=MagicMock(spec=ChatBedrockConverse))
SanitizeThinkingBlocksMiddleware().wrap_model_call(request, lambda req: MagicMock())
assert message.content == [reasoning_block, text_block]
def test_ignores_non_anthropic_models(self) -> None:
thinking_block = {"type": "thinking", "signature": "abc", "thinking": ""}
message = AIMessage(content=[thinking_block, {"type": "text", "text": "ok"}])

View file

@ -710,7 +710,7 @@ def test_process_slack_mention_queues_active_thread_message(
monkeypatch.setattr(webapp, "get_valid_access_token", fake_get_valid_access_token)
async def fake_resolve_agent_model_id(github_login, per_thread_model_id=None):
return "openai:gpt-5.5"
return "bedrock_converse:us.anthropic.claude-opus-4-8"
monkeypatch.setattr(webapp, "resolve_agent_model_id", fake_resolve_agent_model_id)

View file

@ -10,8 +10,8 @@ from agent.dashboard.team_settings import (
get_team_default_grouping_model,
)
_REVIEWER_SUBAGENT_PAIR = ("openai:gpt-5.5", "low")
_GROUPING_PAIR = ("google_genai:gemini-3.5-flash", "low")
_REVIEWER_SUBAGENT_PAIR = ("fireworks:accounts/fireworks/models/deepseek-v4-pro", "low")
_GROUPING_PAIR = ("fireworks:accounts/fireworks/models/kimi-k2p7-code", "low")
def _settings(**overrides: object) -> dict[str, object]:

View file

@ -12,8 +12,8 @@ from agent.dashboard.team_settings import (
get_team_default_model,
)
_AGENT_PAIR = ("anthropic:claude-opus-4-8", "high")
_CHAT_PAIR = ("google_genai:gemini-3.5-flash", "low")
_AGENT_PAIR = ("bedrock_converse:us.anthropic.claude-opus-4-8", "high")
_CHAT_PAIR = ("fireworks:accounts/fireworks/models/kimi-k2p7-code", "low")
def test_org_guidelines_blank_normalizes_to_none() -> None:

215
uv.lock generated
View file

@ -3,7 +3,8 @@ revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version < '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14'",
"python_full_version < '3.12'",
]
[[package]]
@ -251,6 +252,34 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/c1/84fc6811122f54b20de2e5afb312ee07a3a47a328755587d1e505475239b/blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb", size = 13226, upload-time = "2025-12-05T10:43:48.778Z" },
]
[[package]]
name = "boto3"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/9f/897287e955db0f50b12fd69ef45956e4fd2c7ddb48c736872f7ea2314443/boto3-1.43.36.tar.gz", hash = "sha256:587d7ee92a12e440ad12b0e7f11f3358f0c4d65b19f64726efc94aaf194aff28", size = 112690, upload-time = "2026-06-23T02:47:14.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/f1/274303f52483ecf199eae6f8d9b6f5951670397ee4d72c06cfd4eb644612/boto3-1.43.36-py3-none-any.whl", hash = "sha256:42942dde254673abcbc9e6e60017c88341a4f49d99d24e1f2e290fb38138c26f", size = 140031, upload-time = "2026-06-23T02:47:13.178Z" },
]
[[package]]
name = "botocore"
version = "1.43.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7c/37/da9e7f6ca73ac73afd7f0bb7f238aa5daba35c081e98d7f48a7c399599c0/botocore-1.43.36.tar.gz", hash = "sha256:4cae47d1b2d426316b85a0087d9e69e048f13bc003b5177d74639fe9dfd28205", size = 15625488, upload-time = "2026-06-23T02:47:03.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/19/934f81592527a3f7f9b943c893e334c721a4644948642bc33885d584e9ec/botocore-1.43.36-py3-none-any.whl", hash = "sha256:3c65fdc39ed01d8dfde1e961b34038aed03c459f8ddf80717a12ac006475e49d", size = 15313630, upload-time = "2026-06-23T02:46:59.327Z" },
]
[[package]]
name = "bracex"
version = "2.6"
@ -730,7 +759,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.138.1"
version = "0.138.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
@ -739,9 +768,9 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/a9/9f8f7e00195c29836e9bf58bbbaf579e29878b8a67851efff93d9b6d4eb7/fastapi-0.138.2.tar.gz", hash = "sha256:6432359d067a432134620e7c5e4c6e5063e7f37815bbbbf20acef14b0d2e3fc8", size = 420423, upload-time = "2026-06-29T12:44:12.556Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" },
]
[[package]]
@ -1311,6 +1340,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" },
]
[[package]]
name = "jmespath"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
]
[[package]]
name = "jsonpatch"
version = "1.33"
@ -1422,6 +1460,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/14/746235c4da89d9bc6a608c5f489f628e03feb8f697195c146e452c8f23c8/langchain_anthropic-1.4.8-py3-none-any.whl", hash = "sha256:778e9301b6fd517824f76ec1776975ce8add97a1f6a36c50ae3c2f4b03a66f7f", size = 52366, upload-time = "2026-06-26T21:28:45.535Z" },
]
[[package]]
name = "langchain-aws"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "boto3" },
{ name = "langchain-core" },
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" },
{ name = "numpy", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/c6/4065908dc2f113324d1ac31ecf5c907a23863f7aeaab7b9361efd7109563/langchain_aws-1.6.1.tar.gz", hash = "sha256:b5b054f48e2697fa1b96733e9de2accfdd1a4948d9b4e3712e8180c4585cfd2f", size = 538265, upload-time = "2026-06-25T19:02:23.364Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/ab/d85b915c12394385459bb536202227d0fd81377929d71b16f177559ba074/langchain_aws-1.6.1-py3-none-any.whl", hash = "sha256:a121f687b36678239dd96ee9d0503a0b7d5fc4570b3af6d19983ef6ebfaf115e", size = 206315, upload-time = "2026-06-25T19:02:21.848Z" },
]
[[package]]
name = "langchain-core"
version = "1.4.8"
@ -1927,6 +1981,143 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
]
[[package]]
name = "numpy"
version = "2.4.6"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.12'",
]
sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" },
{ url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" },
{ url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" },
{ url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" },
{ url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" },
{ url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" },
{ url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" },
{ url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" },
{ url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" },
{ url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" },
{ url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" },
{ url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" },
{ url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" },
{ url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" },
{ url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" },
{ url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" },
{ url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" },
{ url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" },
{ url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" },
{ url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" },
{ url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" },
{ url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" },
{ url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" },
{ url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" },
{ url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" },
{ url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" },
{ url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" },
{ url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" },
{ url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" },
{ url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" },
{ url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" },
{ url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" },
{ url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" },
{ url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" },
{ url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" },
{ url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" },
{ url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" },
{ url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" },
{ url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" },
{ url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" },
{ url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" },
{ url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" },
{ url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" },
{ url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" },
{ url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" },
{ url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" },
{ url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" },
{ url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" },
{ url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" },
{ url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" },
{ url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" },
{ url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" },
{ url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" },
{ url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" },
{ url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" },
{ url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" },
{ url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" },
{ url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" },
]
[[package]]
name = "numpy"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14'",
"python_full_version >= '3.12' and python_full_version < '3.14'",
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/0a/11486d02add7b1384dff7374d124b1cfbb0ee864dcc9f6a2c0380638cf84/numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561", size = 16789987, upload-time = "2026-06-21T20:56:16.657Z" },
{ url = "https://files.pythonhosted.org/packages/55/b2/285f48640a181947b4587a3766d21ec1eaa7fea833d4b49957e09da467a2/numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6", size = 11760322, upload-time = "2026-06-21T20:56:19.813Z" },
{ url = "https://files.pythonhosted.org/packages/dd/67/b032db1eb03ca30d16eda3b0c22aaa615338b9263c2fd559d0f29451aca4/numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be", size = 5319605, upload-time = "2026-06-21T20:56:22.173Z" },
{ url = "https://files.pythonhosted.org/packages/b9/83/03fc7300c7c6b6c84c487b1dc80d322817b95fbd1f4dd57a85e23b7198de/numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2", size = 6653628, upload-time = "2026-06-21T20:56:23.914Z" },
{ url = "https://files.pythonhosted.org/packages/82/49/2ec21730bc63ccfda829323f7040a8ed4715b3852ce658689cf74ee96a8c/numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8", size = 15153691, upload-time = "2026-06-21T20:56:25.631Z" },
{ url = "https://files.pythonhosted.org/packages/bb/6b/f4a3d0637692c49da8ef99d72d52526f92e0a8d6ac4f0ca9f31441b9d9ea/numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a", size = 16660066, upload-time = "2026-06-21T20:56:28.009Z" },
{ url = "https://files.pythonhosted.org/packages/3a/2f/c354ec86d1f3f5c19649463b0d39652e160736e5b0a4cd18dff0576715c4/numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0", size = 16514638, upload-time = "2026-06-21T20:56:30.26Z" },
{ url = "https://files.pythonhosted.org/packages/06/34/43efdcb319988648580f93c11f1ae82cf7e2faa74925e98e454ae3aa95f8/numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54", size = 18419647, upload-time = "2026-06-21T20:56:32.41Z" },
{ url = "https://files.pythonhosted.org/packages/71/e2/f5d1676b1d7fb682eb5e9a1641e7ebd2414b3216c370661d1029778908b4/numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5", size = 6056688, upload-time = "2026-06-21T20:56:34.657Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/48f115d1c58a34032facebcd51fdf2d02df2c51d4a46a81dd1197bb2ea6b/numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2", size = 12419237, upload-time = "2026-06-21T20:56:36.24Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/2e0882f4044d1b1a1b63e875151fb2393389032022a8b7f5657a7996d3b2/numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca", size = 10339912, upload-time = "2026-06-21T20:56:38.733Z" },
{ url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" },
{ url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" },
{ url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" },
{ url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" },
{ url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" },
{ url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" },
{ url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" },
{ url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" },
{ url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" },
{ url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" },
{ url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" },
{ url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" },
{ url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" },
{ url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" },
{ url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" },
{ url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" },
{ url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" },
{ url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" },
{ url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" },
{ url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" },
{ url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" },
{ url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" },
{ url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" },
{ url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" },
{ url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
]
[[package]]
name = "obstore"
version = "0.8.2"
@ -2002,6 +2193,7 @@ dependencies = [
{ name = "httpx" },
{ name = "langchain" },
{ name = "langchain-anthropic" },
{ name = "langchain-aws" },
{ name = "langchain-daytona" },
{ name = "langchain-fireworks" },
{ name = "langchain-google-genai" },
@ -2031,11 +2223,12 @@ requires-dist = [
{ name = "cryptography", specifier = ">=48.0.1" },
{ name = "deepagents", specifier = "==0.6.12" },
{ name = "exa-py", specifier = ">=2.15.0" },
{ name = "fastapi", specifier = ">=0.138.1" },
{ name = "fastapi", specifier = ">=0.138.2" },
{ name = "fireworks-ai", specifier = ">=1.2.0a71" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-anthropic", specifier = ">=1.4.6" },
{ name = "langchain-aws", specifier = ">=0.2.0" },
{ name = "langchain-daytona", specifier = ">=0.0.7" },
{ name = "langchain-fireworks", specifier = ">=1.4.3" },
{ name = "langchain-google-genai", specifier = ">=4.2.4" },
@ -3108,6 +3301,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b1/47/d8f6b0d5bcf0d4ef9c1894af36d30d5ec275defa4e58dc9c77840377c712/runloop_api_client-1.20.1-py3-none-any.whl", hash = "sha256:b896b63385940bd1083de3256a8b182a3c34419a42389b5c26a2e891972c73f2", size = 380436, upload-time = "2026-05-01T20:13:00.138Z" },
]
[[package]]
name = "s3transfer"
version = "0.19.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/94/dcdaeb1713cab9c84def276cfac7388b17c7d9855bbcfe88d77e4dbafd44/s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685", size = 165171, upload-time = "2026-06-16T19:44:51.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/5f/4c174edad94f82de888ac00a5ddd8d07b35609b6c94f0bdf4d74af57703e/s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262", size = 90101, upload-time = "2026-06-16T19:44:50.439Z" },
]
[[package]]
name = "setuptools"
version = "82.0.1"