From 1c0c3f4ba889154aec8ca2eef80b6fd3a9fef8ab Mon Sep 17 00:00:00 2001 From: ZILECAO Date: Tue, 25 Aug 2026 18:29:28 -0400 Subject: [PATCH 1/3] Add AWS workforce remote coding environments Provide group-based IAM Identity Center access, subject-bound runtime provisioning, the persistent browser terminal, secure OAuth callback relay, operator-managed web addresses, and public setup and migration guidance. --- .agents/skills/migrate-agent-configs/SKILL.md | 173 +++ .../migrate-agent-configs/agents/openai.yaml | 7 + .github/dependabot.yml | 6 + .github/workflows/checks.yml | 2 + AGENTS.md | 8 +- CONTRIBUTING.md | 17 +- README.md | 213 ++-- SECURITY.md | 7 +- agentformation | 6 +- agentformation.example.json | 13 +- docs/identity-center-setup.md | 193 ++++ docs/maintainer-release-checklist.md | 80 +- docs/migrating-local-agent-configs.md | 195 ++++ docs/privacy.md | 19 +- docs/remote-cli-login.md | 61 + docs/security-model.md | 156 ++- scripts/check.sh | 3 + scripts/deploy.sh | 238 +++- scripts/destroy.sh | 14 +- scripts/doctor.sh | 29 +- scripts/lib/common.sh | 44 + scripts/lib/stacks.sh | 2 +- scripts/status.sh | 12 +- scripts/users-add.sh | 87 -- scripts/users-disable.sh | 63 +- scripts/users-enable.sh | 53 + scripts/users-purge.sh | 12 +- templates/foundation.yaml | 81 +- templates/image.yaml | 26 +- templates/provisioning.yaml | 445 ++++++++ templates/runtime.yaml | 57 +- templates/web.yaml | 95 +- web/.env.example | 3 + web/Dockerfile | 6 + web/bun.lock | 6 + web/next.config.ts | 29 +- web/package.json | 2 + web/src/app/api/environment/route.ts | 48 + web/src/app/api/health/route.ts | 10 +- web/src/app/api/oauth/loopback/route.ts | 69 ++ web/src/app/api/session/resume/route.ts | 40 + web/src/app/api/session/start/route.ts | 10 +- web/src/app/api/session/terminate/route.ts | 8 +- web/src/app/api/session/upload/route.ts | 122 +- web/src/app/auth-error/auth-error-content.tsx | 30 + web/src/app/auth-error/page.tsx | 35 +- web/src/app/globals.css | 28 + web/src/app/page.tsx | 57 +- web/src/components/environment-setup.tsx | 287 +++++ web/src/components/mobile-terminal.tsx | 489 ++++++++ web/src/components/oauth-callback-action.tsx | 189 +++ web/src/components/terminal/tabs.test.ts | 31 - web/src/components/terminal/tabs.ts | 40 - .../terminal/terminal-pane-view.tsx | 437 +++++++ web/src/components/terminal/terminal-pane.tsx | 1013 +++++++++++++++-- .../terminal/terminal-shared.test.ts | 88 ++ .../components/terminal/terminal-shared.ts | 820 +++++++++++++ .../terminal/terminal-workspace.tsx | 110 -- web/src/components/terminal/types.ts | 15 - web/src/components/terminal/upload-button.tsx | 101 -- .../components/terminal/use-ssm-terminal.ts | 225 ---- .../terminal/use-terminal-pane-effects.ts | 357 ++++++ .../terminal/use-terminal-resize.ts | 57 - .../use-terminal-text-selection.test.tsx | 147 +++ .../terminal/use-terminal-text-selection.ts | 264 +++++ web/src/lib/api-error.ts | 17 +- web/src/lib/auth-provider.test.ts | 18 + web/src/lib/auth-provider.ts | 20 + web/src/lib/auth.ts | 24 +- web/src/lib/authorization.ts | 19 +- web/src/lib/aws.ts | 14 + web/src/lib/content-security-policy.test.ts | 23 + web/src/lib/content-security-policy.ts | 25 + web/src/lib/env.ts | 13 + web/src/lib/environment-progress.test.ts | 30 + web/src/lib/environment-progress.ts | 109 ++ web/src/lib/foundation-template.test.ts | 20 + web/src/lib/oauth-callback.test.ts | 43 + web/src/lib/oauth-callback.ts | 82 ++ web/src/lib/oauth-relay.test.ts | 25 + web/src/lib/oauth-relay.ts | 33 + web/src/lib/provisioning-status.ts | 32 + web/src/lib/provisioning-template.test.ts | 33 + web/src/lib/provisioning.test.ts | 19 + web/src/lib/provisioning.ts | 29 + web/src/lib/request-security.test.ts | 70 ++ web/src/lib/request-security.ts | 19 + web/src/lib/runtime-access.test.ts | 16 + web/src/lib/runtime-access.ts | 23 +- web/src/proxy.ts | 32 + 90 files changed, 7189 insertions(+), 1189 deletions(-) create mode 100644 .agents/skills/migrate-agent-configs/SKILL.md create mode 100644 .agents/skills/migrate-agent-configs/agents/openai.yaml create mode 100644 docs/identity-center-setup.md create mode 100644 docs/migrating-local-agent-configs.md create mode 100644 docs/remote-cli-login.md delete mode 100755 scripts/users-add.sh create mode 100755 scripts/users-enable.sh create mode 100644 templates/provisioning.yaml create mode 100644 web/src/app/api/environment/route.ts create mode 100644 web/src/app/api/oauth/loopback/route.ts create mode 100644 web/src/app/api/session/resume/route.ts create mode 100644 web/src/app/auth-error/auth-error-content.tsx create mode 100644 web/src/components/environment-setup.tsx create mode 100644 web/src/components/mobile-terminal.tsx create mode 100644 web/src/components/oauth-callback-action.tsx delete mode 100644 web/src/components/terminal/tabs.test.ts delete mode 100644 web/src/components/terminal/tabs.ts create mode 100644 web/src/components/terminal/terminal-pane-view.tsx create mode 100644 web/src/components/terminal/terminal-shared.test.ts create mode 100644 web/src/components/terminal/terminal-shared.ts delete mode 100644 web/src/components/terminal/terminal-workspace.tsx delete mode 100644 web/src/components/terminal/types.ts delete mode 100644 web/src/components/terminal/upload-button.tsx delete mode 100644 web/src/components/terminal/use-ssm-terminal.ts create mode 100644 web/src/components/terminal/use-terminal-pane-effects.ts delete mode 100644 web/src/components/terminal/use-terminal-resize.ts create mode 100644 web/src/components/terminal/use-terminal-text-selection.test.tsx create mode 100644 web/src/components/terminal/use-terminal-text-selection.ts create mode 100644 web/src/lib/auth-provider.test.ts create mode 100644 web/src/lib/auth-provider.ts create mode 100644 web/src/lib/content-security-policy.test.ts create mode 100644 web/src/lib/content-security-policy.ts create mode 100644 web/src/lib/environment-progress.test.ts create mode 100644 web/src/lib/environment-progress.ts create mode 100644 web/src/lib/foundation-template.test.ts create mode 100644 web/src/lib/oauth-callback.test.ts create mode 100644 web/src/lib/oauth-callback.ts create mode 100644 web/src/lib/oauth-relay.test.ts create mode 100644 web/src/lib/oauth-relay.ts create mode 100644 web/src/lib/provisioning-status.ts create mode 100644 web/src/lib/provisioning-template.test.ts create mode 100644 web/src/lib/provisioning.test.ts create mode 100644 web/src/lib/provisioning.ts create mode 100644 web/src/lib/request-security.test.ts create mode 100644 web/src/lib/request-security.ts create mode 100644 web/src/proxy.ts diff --git a/.agents/skills/migrate-agent-configs/SKILL.md b/.agents/skills/migrate-agent-configs/SKILL.md new file mode 100644 index 0000000..f05f5e0 --- /dev/null +++ b/.agents/skills/migrate-agent-configs/SKILL.md @@ -0,0 +1,173 @@ +--- +name: migrate-agent-configs +description: Safely migrate a person's local Codex and Claude Code settings, session history, and explicitly approved credentials into their assigned AgentFormation runtime through AWS Systems Manager. Use for onboarding or restoring an existing AgentFormation runtime; do not use for an untrusted host or as a general EC2 backup tool. +--- + +# Migrate local agent configs + +Move only the local agent state the user chooses into one existing, +user-assigned AgentFormation runtime. Preserve the public template's Bedrock +defaults unless the user explicitly asks to replace them. + +Before acting, read +[the AgentFormation migration guide](../../../docs/migrating-local-agent-configs.md) +completely. Treat its security and cleanup rules as required. + +## Establish the exact scope + +Discover read-only facts before asking the user for anything the repository or +AWS account can answer: + +- the AWS profile and region; +- the AgentFormation deployment and upload bucket; +- the exact managed EC2 instance assigned to this user; +- the local and remote home directories; +- which Codex and Claude Code files exist and their sizes; and +- whether the remote runtime already contains newer settings or sessions. + +Show a short inventory grouped as settings, sessions/history, credentials, MCP +connections, source repositories, and disposable files. Get explicit approval +before moving any credentials or session history. Approval to migrate settings +does not include credentials, tokens, browser cookies, keychains, chat history, +or uncommitted source code. + +## Hard boundaries + +- Verify the target instance through trusted AgentFormation state, CloudFormation + outputs, registry data, and the `AgentFormationManaged` tags. Never accept a + browser-supplied or unverified instance ID. +- Touch only the selected user's runtime. Do not stop, update, or inspect another + person's runtime. +- Never print secret values, include them in command arguments, commit them, or + put them in SSM Run Command parameters. AWS retains command history. +- Never upload a plaintext archive. Client-side encrypt the archive before it + leaves the source machine, even when the S3 bucket also encrypts objects. +- Keep the one-time private unwrap key on the destination runtime only. Return + only its public key to the source machine. +- Use exact, random, user-scoped S3 object keys. Delete the exact objects after + validation and verify that the temporary prefix is empty. +- Create local and remote scratch directories with `mktemp -d`. Record their + exact paths, use a cleanup trap, and never run recursive deletion against a + home directory, `/workspace`, a repository root, an unresolved variable, or a + glob. +- Back up replaced remote files into a timestamped, permission-restricted folder + before installing anything. Do not overwrite newer remote state without the + user's approval. +- Merge approved local state with remote-only state. A previous partial migration + or a currently used runtime must never be treated as an empty destination. +- Preserve owner and permissions. Credential files must be owned by the + `agentformation` user and mode `0600`; private directories must be `0700`. +- Stop if the target identity is ambiguous, SSM is offline, encrypted staging is + unavailable, archive integrity fails, or cleanup cannot be verified. + +## Choose portable state + +### Codex + +Normally consider: + +- `~/.codex/config.toml`, global `AGENTS.md`, rules, hooks, skills, and intentional + plugin configuration; +- `~/.codex/sessions/`, archived sessions, history, memories, goals, and indexes + when the user approves chat-history migration; and +- `~/.codex/auth.json` only when the user explicitly wants the remote runtime to + use the same ChatGPT login instead of AgentFormation's default Bedrock provider. + +Use SQLite's `.backup` command for live Codex databases instead of copying open +database files. Exclude logs, sockets, process state, temporary files, build +caches, macOS-only helpers, and bundled binaries that should be installed for the +remote architecture. Rewrite local absolute paths and disable non-portable MCP +servers instead of making them required and blocking Codex startup. + +Inventory and compare every portable Codex category: configuration values, +status line, global instructions, rules, hooks, personal skills, intentional +plugin selections, MCP names, session files, archived sessions, prompt history, +session names, thread indexes, memories, and goals. Compare hashes or row counts +where practical without printing contents. Keep newer Linux-installed system +skills and plugins rather than replacing them with an older macOS cache. + +OpenAI documents copying `~/.codex/auth.json` as a supported headless-login +fallback. It also says to treat that file like a password. If the source login is +in a keychain instead of the file, prefer a fresh remote `codex login +--device-auth`; do not export the keychain behind the user's back. + +### Claude Code + +Normally consider: + +- `~/.claude/settings.json`, user instructions, skills, hooks, and intentional MCP + configuration; and +- session/project history only when the user approves it after seeing its size. + +`~/.claude.json` can contain sign-in state, MCP configuration, trust decisions, +and machine-specific project paths. Inspect its keys without printing values, +copy only the approved parts, and rewrite source-machine paths. AgentFormation's +default Claude Code setup uses the runtime's AWS role with Bedrock and needs no +personal Claude login. Prefer a fresh supported remote login over copying a +Claude credential or operating-system keychain. + +Exclude debug logs, telemetry, caches, backups, sockets, lock files, and binaries. +Confirm the installed Claude Code version understands every migrated setting. + +### Repositories and other tools + +Prefer a fresh authenticated `git clone`. Use a Git bundle when the destination +must receive exact committed history without direct repository access. Move +uncommitted work only with separate, explicit approval and inspect it for secrets +first. Do not copy `node_modules`, build output, virtual environments, Docker +state, or platform-specific binaries. + +Prefer fresh device login for GitHub and MCP services. OAuth tokens may be stored +in a local OS keychain and are intentionally not portable. On AgentFormation, +use the page's **OAuth** helper when a remote MCP login redirects the browser to +`127.0.0.1` or `localhost`. + +## Transfer through SSM + +1. Generate a one-time asymmetric key pair inside the remote scratch directory + through an SSM session or Run Command. Keep the private key remote and retrieve + only the public key. +2. Build an allowlisted archive in the local scratch directory. Create consistent + SQLite backups first. Record a manifest of paths, sizes, modes, and hashes + without recording credential contents. On macOS, disable Apple archive + metadata and reject AppleDouble entries such as `._payload`. +3. Generate a random symmetric secret locally, encrypt the archive, wrap the + secret with the remote public key, and calculate a SHA-256 digest of the + ciphertext. Never place the secret in a shell argument, SSM parameter, log, or + S3 object name. +4. Upload only the ciphertext and wrapped secret to the existing encrypted + AgentFormation upload bucket under the exact assigned user's prefix. +5. Through SSM, have the destination download the two objects with its instance + role, verify the ciphertext digest, unwrap and decrypt inside the remote + scratch directory, and reject unexpected archive paths before extraction. +6. Install the approved files as `agentformation`, normalizing Linux paths and + permissions. Merge session/history data so newer remote-only chats survive. + For migrated Codex history, back up the merged SQLite index, map copied + rollout paths to the matching remote Codex `sessions/` or + `archived_sessions/` directory, map copied unarchived user-chat working + folders to the runtime start folder, and set `tui.resume_cwd = "current"`. + Confirm every indexed rollout resolves to a regular file under one of those + remote directories. Preserve the original paths in the restricted rollback + copy; never rewrite message content merely to change a path. + Keep the original AgentFormation Bedrock config as a clearly named backup + whenever provider settings change. +7. Validate before deleting backups: start both CLIs, check their reported auth + mode, list configured MCP servers, compare every approved settings category, + and prove that ordinary `codex resume -C /workspace` lists migrated interactive + chats. Also run + `codex resume --all --include-non-interactive -C /workspace` and directly + resume one approved session by ID if history was copied. File counts or + database row counts alone do not validate a migration. Perform a harmless + read-only prompt when the chosen provider permits it. +8. Remove the exact S3 objects, one-time remote key material, decrypted archive, + local scratch directory, and temporary token files. Verify each is gone. Keep + the timestamped remote rollback copy until the user accepts the migration. + +## Report the result + +State which categories matched already, which moved, which were merged, which +were deliberately skipped, the target instance, the validation performed, +whether the default Bedrock provider changed, where the rollback copy lives, and +whether every temporary local, S3, and remote artifact was removed. Never claim +completion from file presence alone, and never include credential values or full +sensitive paths in the report. diff --git a/.agents/skills/migrate-agent-configs/agents/openai.yaml b/.agents/skills/migrate-agent-configs/agents/openai.yaml new file mode 100644 index 0000000..4612023 --- /dev/null +++ b/.agents/skills/migrate-agent-configs/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "Migrate Agent Configs" + short_description: "Move local agent state to AgentFormation" + default_prompt: "Use $migrate-agent-configs to safely move my approved Codex and Claude Code settings into my assigned AgentFormation runtime." + +policy: + allow_implicit_invocation: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d89ad64..051d5c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,6 +4,8 @@ updates: directory: /web schedule: interval: weekly + cooldown: + default-days: 7 open-pull-requests-limit: 5 groups: web-minor-and-patch: @@ -18,6 +20,8 @@ updates: directory: /web schedule: interval: weekly + cooldown: + default-days: 7 open-pull-requests-limit: 2 ignore: - dependency-name: library/node @@ -26,4 +30,6 @@ updates: directory: / schedule: interval: weekly + cooldown: + default-days: 7 open-pull-requests-limit: 5 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0c47dd4..fa222fa 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,6 +34,7 @@ jobs: env: AUTH_COGNITO_ID: build-client AUTH_COGNITO_SECRET: build-secret + AUTH_COGNITO_IDENTITY_PROVIDER: IdentityCenter AUTH_COGNITO_ISSUER: https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build AUTH_SECRET: build-only-secret-with-at-least-32-characters AWS_REGION: us-east-1 @@ -51,6 +52,7 @@ jobs: --env AUTH_TRUST_HOST=true \ --env AUTH_COGNITO_ID=container-client \ --env AUTH_COGNITO_SECRET=container-secret \ + --env AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter \ --env AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_container \ --env AUTH_SECRET=container-only-secret-with-at-least-32-characters \ --env AWS_REGION=us-east-1 \ diff --git a/AGENTS.md b/AGENTS.md index 7fa1f69..c38b18d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,10 +4,12 @@ AgentFormation is a community reference project for deploying private, persisten ## Boundaries -- Never commit real email addresses, AWS account IDs, credentials, generated passwords, deployment state, or environment-specific resource names. +- Never commit real email addresses, AWS account IDs, credentials, IAM Identity Center metadata addresses or XML, deployment state, or environment-specific resource names. - `agentformation.local.json`, `.env*`, and `.agentformation/` are local-only. -- Cognito subjects, not browser-supplied instance IDs or email strings, are the authorization boundary. -- An AWS account administrator is trusted. The application isolates invited users from each other; it cannot isolate resources from the administrator who owns the AWS account. +- Federated Cognito subjects, not browser-supplied instance IDs or email strings, are the authorization boundary. +- IAM Identity Center group assignment is the only employee login. Do not add a local password, self-sign-up, or app-specific MFA. +- The web role may start only the fixed provisioning state machine. Keep runtime templates and operator-selected AWS parameters out of browser input. +- An AWS account administrator is trusted. The application isolates assigned users from each other; it cannot isolate resources from the administrator who owns the AWS account. - Keep GitHub Actions validation-only. Do not add an AWS deployment workflow or static AWS credentials. ## Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4f68ef..a8b4810 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,9 +6,16 @@ Thanks for helping improve AgentFormation. - Open an issue first for large architecture or security changes. - Never commit AWS credentials, account identifiers, private email addresses, - terminal history, `.env` files, or `agentformation.local.json`. -- Keep Cognito subject-to-runtime authorization on the server. The browser must - never supply or select an EC2 instance ID. + IAM Identity Center metadata addresses or XML, terminal history, `.env` files, + or `agentformation.local.json`. +- Keep the federated Cognito subject-to-runtime authorization on the server. The + browser must never supply or select an EC2 instance ID. +- Preserve IAM Identity Center as the only employee sign-in. Do not add a local + password, self-sign-up, app-specific MFA, or browser-selected provisioning + parameters. +- Keep environment creation behind the fixed Step Functions job, content-hashed + runtime template, conditional one-per-subject record, and restricted + CloudFormation service role. - Pin tool versions and GitHub Actions. Explain version upgrades in the pull request. - Do not add an AWS deployment workflow that receives credentials from public pull @@ -46,4 +53,6 @@ deployment tests stay in the maintainer checklist. ## Pull requests Describe what changed, why, security and privacy effects, test evidence, and any -AWS resources or costs affected. Keep unrelated cleanup in a separate change. +AWS resources or costs affected. Authentication or provisioning changes must also +state how assigned and unassigned Identity Center users were tested. Keep +unrelated cleanup in a separate change. diff --git a/README.md b/README.md index b5ac588..821912e 100644 --- a/README.md +++ b/README.md @@ -1,92 +1,171 @@ # AgentFormation -An AWS-native solution template for persistent remote coding agents. +An AWS-native template for private, persistent remote coding environments. -AgentFormation gives each invited user a private EC2 workspace with both +AgentFormation gives each approved employee a private EC2 workspace with both [Claude Code](https://docs.anthropic.com/en/docs/claude-code) and -[Codex](https://developers.openai.com/codex/) configured to use Amazon Bedrock. -A small web app provides authenticated browser terminals, persistent `tmux` -sessions, and file uploads. Everything deploys into an AWS account you control. +[Codex](https://developers.openai.com/codex/) configured for Amazon Bedrock. A +small web app provides browser terminals, persistent `tmux` sessions, and file +uploads. Everything runs in an AWS account you control. + +Employees sign in with the same AWS IAM Identity Center account they already use +for company AWS access. AgentFormation does not keep a separate username, +password, or authenticator-app setting. An administrator normally assigns a +dedicated AgentFormation access group to the app, and each assigned employee can +create exactly one reviewed coding environment for themself. > [!IMPORTANT] -> The first deployment builds a complete runtime image and an App Runner service. +> The first complete deployment builds a runtime image and an App Runner service. > It commonly takes more than 30 minutes. This is normal for the AWS services used -> by this template, not a frozen terminal. +> by the template, not a frozen terminal. ## What it creates ```text -invited user -> Cognito sign-in -> App Runner web terminal - | - v - DynamoDB assignment - | - v - private EC2 runtime (one/user) - Claude Code + Codex + /workspace - | - v - Amazon Bedrock models +assigned employee group + | + v +AWS IAM Identity Center --SAML--> Cognito bridge --OIDC--> App Runner + web terminal + | + Create environment | + v + restricted setup job + | + v + private EC2 runtime + Claude Code + Codex + /workspace + | + v + Amazon Bedrock models ``` -- Amazon Cognito with self-sign-up disabled +- IAM Identity Center group assignment as the only employee sign-in +- Amazon Cognito as an invisible SAML-to-OIDC bridge, with local sign-in excluded - one private VPC subnet and one NAT gateway by default -- one private, encrypted EC2 runtime and EBS volume per user +- one private, encrypted EC2 runtime and EBS volume per employee - AWS Systems Manager Session Manager instead of inbound SSH - an EC2 Image Builder pipeline with pinned Claude Code and Codex versions - an App Runner web terminal +- a restricted Step Functions job that can create only the reviewed runtime stack - a DynamoDB identity-to-runtime registry - a short-lived, encrypted S3 upload staging area -You do **not** need AWS Organizations, Google Workspace, or Google OAuth. You do -need an AWS account, an AWS CLI profile with permission to create the resources, -Docker with `buildx`, `jq`, and access to the selected Bedrock models. +You need an organization instance of IAM Identity Center, permission to add a +customer-managed SAML application and assign a group, an AWS CLI profile that can +deploy the resources, Docker with `buildx`, `jq`, and access to the selected +Bedrock models. The AWS root user is deliberately not an app login. Root is a +separate emergency identity and should not be used for daily work. + +AWS does not expose customer-managed SAML application creation or attribute +mapping through its public CLI, API, or CloudFormation resource. Creating the +Identity Center application and entering its two attribute mappings is therefore +a one-time console step; AgentFormation automates the Cognito side and the rest of +the deployment. -If your organization requires CloudFormation to use an existing service role, -add its ARN as `cloudFormationRoleArn` in `agentformation.local.json`. The example -omits it because most personal AWS accounts do not need one. +If CloudFormation must use an existing service role, add its ARN as +`cloudFormationRoleArn` in `agentformation.local.json`. The example omits it +because not every account requires one. ## Quick start -1. Clone the repository and create a private local configuration: +1. Clone the repository and create the private local configuration: ```bash cp agentformation.example.json agentformation.local.json + AWS_PROFILE=your-profile ./agentformation doctor + AWS_PROFILE=your-profile ./agentformation deploy ``` -2. Replace `admin@example.com`, review the instance size and Bedrock model IDs, - then run: + The first deploy creates only enough identity infrastructure to print the + exact SAML ACS URL and audience, then stops safely. + +2. In IAM Identity Center, create a customer-managed SAML 2.0 application using + those two printed values. Map SAML `Subject` to `${user:subject}` with the + `persistent` format, map `email` to `${user:email}` with the `unspecified` + format, assign a dedicated AgentFormation access group, add one test employee + directly to that group, and + copy the HTTPS address shown for the **IAM Identity Center SAML metadata + file**. Using the address lets Cognito refresh signing certificates + automatically. If your console offers only a download, save the XML file + instead. + +3. Put the metadata address in the ignored private config and deploy again. Do + not commit the organization-specific address: ```bash + # In agentformation.local.json, set identityCenter.metadataUrl to the HTTPS + # address from IAM Identity Center and leave metadataFile empty. AWS_PROFILE=your-profile ./agentformation doctor AWS_PROFILE=your-profile ./agentformation deploy ``` -3. Open the printed web address and use the temporary password from the Cognito - invitation. Cognito asks you to choose a permanent password on first sign-in. + If you downloaded XML instead, save it as + `.agentformation/identity-center-metadata.xml`, leave `metadataUrl` empty, and + set `metadataFile` to that path. Set only one metadata source. + +4. Open the printed web address and choose **Continue with company SSO**. If your + company SSO session is still active, there is normally no second prompt. Choose + **Create environment** once; the reviewed AWS job creates your private runtime. + +5. When a command-line tool opens a browser login that ends at + `127.0.0.1` or `localhost`, the browser will show + `ERR_CONNECTION_REFUSED`. This is expected for a remote runtime. Copy the + complete failed address, return to AgentFormation, choose **Finish login**, + paste it, and choose **Send to runtime** while the CLI is still waiting. Never + paste that one-time address into chat or logs. Follow the + [remote CLI sign-in guide](docs/remote-cli-login.md) for the complete flow and + troubleshooting steps. + +6. As the optional final setup step, migrate a person's existing Codex and Claude + Code preferences or approved session history into their assigned runtime. Start + Codex from this repository and invoke `$migrate-agent-configs`; it begins with a + read-only inventory and requires separate approval before moving credentials or + chats. A complete migration maps the copied chat index to the remote workspace + so ordinary `codex resume` and the in-app `/resume` command work. Use + `codex resume --all --include-non-interactive -C /workspace` as the all-folders + fallback. Follow the + [local agent migration guide](docs/migrating-local-agent-configs.md). + +See [the complete IAM Identity Center setup guide](docs/identity-center-setup.md) +for the exact console fields and group-assignment steps. + +The deploy command is safe to run again. CloudFormation applies reviewed changes +without creating a second runtime for an existing company identity. The latest +tested AMI is reused; set `AGENTFORMATION_REBUILD_IMAGE=1` only when you +intentionally want a fresh image with otherwise unchanged settings. + +### Custom web address + +App Runner supplies a working HTTPS address automatically. To use a company +address instead, first associate that custom domain with the App Runner service +and publish the certificate-validation and traffic records requested by App +Runner through your DNS provider. Wait until App Runner reports the domain as +active, then put only the origin in the ignored local configuration: + +```json +"publicUrl": "https://agents.example.com" +``` -The deployment command is safe to run again. CloudFormation applies changes and -the user command keeps the same runtime stack for an existing Cognito identity. -It reuses the newest tested AMI from the current pipeline; set -`AGENTFORMATION_REBUILD_IMAGE=1` when you intentionally want a fresh runtime -image with otherwise unchanged settings. +Run `./agentformation doctor` and `./agentformation deploy` again. The deploy +command uses that address for Auth.js, Cognito callbacks and logout, browser +upload restrictions, and status output. Do not commit a company hostname to the +public repository. Leave `publicUrl` empty to keep using the generated App Runner +address. ## Daily administration ```bash -# Show shared stacks, users, runtimes, and the web address +# Show shared stacks, employee runtimes, and the web address AWS_PROFILE=your-profile ./agentformation status -# Invite a user and create one runtime -AWS_PROFILE=your-profile ./agentformation users add --email person@example.com - -# Block sign-in and stop the runtime while preserving its disk +# Immediately block app sign-in and stop compute while preserving the disk AWS_PROFILE=your-profile ./agentformation users disable --email person@example.com -# Re-enable the identity and restart its preserved runtime -AWS_PROFILE=your-profile ./agentformation users add --email person@example.com +# Restore app sign-in and restart the preserved runtime +AWS_PROFILE=your-profile ./agentformation users enable --email person@example.com -# Permanently delete a user, runtime, and runtime disk +# Permanently delete the app identity, runtime, and runtime disk AWS_PROFILE=your-profile ./agentformation users purge \ --email person@example.com --confirm DELETE @@ -94,26 +173,32 @@ AWS_PROFILE=your-profile ./agentformation users purge \ AWS_PROFILE=your-profile ./agentformation destroy --confirm DELETE ``` -Users start in `/workspace`. They can create project folders directly underneath -it. Closing a browser does not kill the `tmux` session, so reconnecting returns to -the same terminal process. +Group assignment in IAM Identity Center is the source of truth. Remove an +employee from the assigned group when access should end. Use `users disable` for +an immediate app-side block that preserves their disk. Remove the group assignment +before `users purge`; otherwise the still-approved employee can sign in again and +create a new environment. + +Users start in `/workspace`. Closing a browser does not kill the `tmux` session, +so reconnecting returns to the same terminal process. ## Security model AgentFormation isolates ordinary app users from one another, but the AWS account -administrator remains trusted and can inspect or change all resources. The browser -cannot choose an instance ID; the server derives the signed-in Cognito subject and -looks up its assigned runtime. Runtimes have no public IP and no inbound security -group rules. +administrator remains trusted and can inspect or change all resources. The +browser cannot choose an instance ID. The server uses the signed-in federated +Cognito subject to find the assigned runtime, and the setup job accepts only a +fixed, content-hashed CloudFormation template and fixed operator-selected values. +Runtimes have no public IP and no inbound security group rules. Read [the security model](docs/security-model.md) and -[the privacy notes](docs/privacy.md) before inviting users. To report a +[the privacy notes](docs/privacy.md) before assigning users. To report a vulnerability, follow [SECURITY.md](SECURITY.md). ## Cost and cleanup -This is not a free-tier-only template. The main always-on or usage-based costs are -App Runner, a NAT gateway, one EC2 instance and EBS volume per user, Image Builder, +This is not a free-tier-only template. The main costs are App Runner, a NAT +gateway, one EC2 instance and EBS volume per user, Image Builder, Step Functions, S3, DynamoDB, and Bedrock requests. Check the [AWS Pricing Calculator](https://calculator.aws/) for your region and sizes. Disabling a user stops EC2 compute but preserves EBS storage. Use `purge` or @@ -122,23 +207,13 @@ Disabling a user stops EC2 compute but preserves EBS storage. Use `purge` or ## Development ```bash -cd web -bun install --frozen-lockfile -bun audit -bun run format:check -bun run lint -bun run typecheck -bun run test -bun run build - -cd .. -shellcheck -x -P SCRIPTDIR scripts/*.sh scripts/lib/*.sh agentformation -cfn-lint templates/*.yaml +./scripts/check.sh ``` -Pull requests run these checks without receiving AWS credentials. A full AWS -deployment is deliberately a maintainer-run release check because it creates -billable resources and requires account-specific Bedrock access. +The check runs the frozen install, dependency audit, formatting, lint, types, +tests, production build, shell checks, and CloudFormation lint. Pull requests do +not receive AWS credentials. A full AWS deployment remains a maintainer-run check +because it creates billable resources and uses account-specific Bedrock access. See [CONTRIBUTING.md](CONTRIBUTING.md) and the [maintainer release checklist](docs/maintainer-release-checklist.md). diff --git a/SECURITY.md b/SECURITY.md index b43a90c..bef51c1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,6 +18,7 @@ forks are not maintained. ## Operator responsibility -The operator owns the AWS account, identity invitations, Bedrock access, costs, -backups, updates, and deletion. Review changes before deploying them and use a -separate non-production AWS account for evaluation. +The operator owns the AWS account, IAM Identity Center application and group +assignments, Bedrock access, costs, backups, updates, and deletion. Review changes +before deploying them and use a separate non-production AWS account for +evaluation. diff --git a/agentformation b/agentformation index 52bdb6a..6c8bfc9 100755 --- a/agentformation +++ b/agentformation @@ -13,11 +13,11 @@ case "$COMMAND" in SUBCOMMAND="${1:-help}" if [[ $# -gt 0 ]]; then shift; fi case "$SUBCOMMAND" in - add|disable|purge) + disable|enable|purge) exec "$ROOT_DIR/scripts/users-$SUBCOMMAND.sh" "$@" ;; *) - echo "Usage: ./agentformation users {add|disable|purge}" >&2 + echo "Usage: ./agentformation users {disable|enable|purge}" >&2 exit 2 ;; esac @@ -30,8 +30,8 @@ Usage: ./agentformation doctor ./agentformation deploy ./agentformation status - ./agentformation users add [--email ADDRESS] [--suppress-invite] ./agentformation users disable [--email ADDRESS] + ./agentformation users enable [--email ADDRESS] ./agentformation users purge [--email ADDRESS] --confirm DELETE ./agentformation destroy --confirm DELETE diff --git a/agentformation.example.json b/agentformation.example.json index 1de7573..82626c2 100644 --- a/agentformation.example.json +++ b/agentformation.example.json @@ -1,7 +1,12 @@ { "deploymentName": "agentformation", "region": "us-east-1", + "publicUrl": "", "networkMode": "private-nat", + "identityCenter": { + "metadataUrl": "", + "metadataFile": "" + }, "runtime": { "architecture": "arm64", "instanceType": "m7g.xlarge", @@ -12,12 +17,8 @@ "codex": "openai.gpt-5.6-sol" }, "versions": { + "awsCli": "2.36.29", "claudeCode": "2.1.235", "codex": "0.148.0" - }, - "users": [ - { - "email": "admin@example.com" - } - ] + } } diff --git a/docs/identity-center-setup.md b/docs/identity-center-setup.md new file mode 100644 index 0000000..ab5b6e9 --- /dev/null +++ b/docs/identity-center-setup.md @@ -0,0 +1,193 @@ +# IAM Identity Center sign-in setup + +AgentFormation uses your organization's existing AWS IAM Identity Center session. +It does not ask employees to create another password or configure another MFA +method. Amazon Cognito sits between Identity Center and the web app only to turn +the SAML company sign-in into the OIDC tokens used by the app. + +This setup requires an **organization instance** of IAM Identity Center and an +administrator who can add customer-managed applications and assign groups. An AWS +account instance of Identity Center is not enough for customer-managed SAML apps. + +AWS currently allows customer-managed SAML application creation and SAML +attribute mapping only in the IAM Identity Center console. Its +[public application API](https://docs.aws.amazon.com/singlesignon/latest/APIReference/API_CreateApplication.html) +and CloudFormation resource support customer-managed OAuth applications but not +this SAML configuration. Sections 2 through 4 are therefore one-time console +work. The AgentFormation command handles the Cognito connection and all other +deployment resources. + +## 1. Create the identity bootstrap + +Leave both `identityCenter.metadataUrl` and `identityCenter.metadataFile` empty in +`agentformation.local.json`, then run: + +```bash +AWS_PROFILE=your-profile ./agentformation deploy +``` + +The command creates the Cognito user pool and prints two account-specific values: + +```text +SAML ACS URL: https://... +SAML audience: urn:amazon:cognito:sp:... +``` + +The command then stops. This is expected; the web app is not published with a +local password fallback. + +## 2. Add the SAML application + +In the IAM Identity Center console: + +1. Open **Applications**, choose **Customer managed**, then **Add application**. +2. Choose **I have an application I want to set up**, then **SAML 2.0**. +3. Use `AgentFormation` as the display name and add a description your employees + will recognize. +4. Under **IAM Identity Center metadata**, copy the HTTPS address shown for the + **IAM Identity Center SAML metadata file**. The **Default (IPv4 only)** address + is sufficient unless your organization specifically requires dual-stack + endpoints. Keep this address private to your organization. +5. Leave **Application start URL** and **Relay state** blank. The default one-hour + session duration is a reasonable starting point; your organization's normal + Identity Center and upstream identity-provider policies still apply. +6. Under **Application metadata**, choose **Manually type your metadata values**. +7. Paste the printed **SAML ACS URL** into **Application ACS URL**. +8. Paste the printed **SAML audience** into **Application SAML audience**. +9. Choose **Submit**. + +If the console exposes only a **Download** action rather than an address, download +the complete metadata XML. AgentFormation supports that file as a fallback. + +AgentFormation accepts service-provider-initiated sign-ins only. Employees begin +at the AgentFormation web address and are sent to Identity Center; an unsolicited +SAML response is not accepted. + +## 3. Map the stable identity and employee email + +On the new application's detail page, choose **Actions**, then **Edit attribute +mappings**. Set these mappings: + +| Application attribute | IAM Identity Center value | Format | +| --------------------- | ------------------------- | ------------- | +| `Subject` | `${user:subject}` | `persistent` | +| `email` | `${user:email}` | `unspecified` | + +The employee email must be present and unique in Identity Center. Cognito requests +a persistent SAML NameID, so the `Subject` row must use Identity Center's stable +`${user:subject}` value with the `persistent` format. The separate `email` row is +used for display and administration. AgentFormation uses Cognito's stable +federated subject, not the email string, as the actual runtime access key. + +## 4. Assign a dedicated access group + +On the application's **Assigned users and groups** tab, choose **Assign users and +groups**. The recommended setup is a dedicated group such as +`AgentFormationUsers`. Create and manage it in the directory that owns your +workforce identities: the built-in Identity Center directory, or an external +provider such as Okta or Google Workspace when users and groups are synchronized +into AWS. Do not create a second manual copy of an externally managed group. Add +one test employee directly to the synchronized group, then assign the group to +the application. IAM Identity Center does not honor nested-group membership for +application assignments. + +You may reuse an existing employee or developer group only when every direct +member should be allowed to create an AgentFormation runtime and incur its AWS +cost. For a one-person test, directly assigning that one Identity Center user to +the application is also acceptable; replace the direct assignment with the +dedicated group before a wider rollout. + +Application assignment controls who can sign in to AgentFormation. It does not +grant those employees new AWS console roles or permission sets. An IAM user is a +different kind of identity and cannot be added to an IAM Identity Center group; +assign the employee's Identity Center user or group instead. + +An Identity Center administrator can assign a broader admin group too. Do not use +the AWS root user: root is a separate emergency identity and does not sign in +through Identity Center. + +## 5. Finish the deployment + +Put the copied metadata address in `agentformation.local.json`, which Git ignores: + +```json +"identityCenter": { + "metadataUrl": "https://your-identity-center-metadata-address", + "metadataFile": "" +} +``` + +The metadata contains organization-specific SAML endpoints and public signing +certificates. It is not a password or private key, but it still does not belong in +the public repository, issue comments, screenshots, or logs. + +If you downloaded the XML fallback, save it under the ignored local state +directory: + +```text +.agentformation/identity-center-metadata.xml +``` + +Then use this configuration instead: + +```json +"identityCenter": { + "metadataUrl": "", + "metadataFile": ".agentformation/identity-center-metadata.xml" +} +``` + +Set exactly one of `metadataUrl` or `metadataFile`, never both. + +Then run: + +```bash +AWS_PROFILE=your-profile ./agentformation doctor +AWS_PROFILE=your-profile ./agentformation deploy +``` + +The deploy command configures the Cognito SAML bridge and does not print the +metadata or generated client secret. A metadata URL is preferred because Cognito +refreshes it automatically, normally about every six hours or before the metadata +expires. With a downloaded XML file, rerun the deployment whenever Identity +Center signing metadata changes. + +## 6. Verify the employee experience + +After adding a new application or assignment, sign out of the AWS access portal +and sign back in before testing. AWS can take up to one hour to show a newly +assigned application inside an existing portal session. A private browser window +is a simple way to force a fresh sign-in. + +1. Open the printed AgentFormation address in the fresh browser session. +2. Choose **Continue with company SSO**. +3. Confirm the browser goes directly to your normal company sign-in. Once the new + assignment is visible to Identity Center, the employee returns without a + separate AgentFormation credential prompt. +4. Choose **Create environment**. +5. Wait for the page to report the environment is ready, then confirm the terminal + opens in `/workspace`. + +Test with one assigned employee and one unassigned employee before wider use. The +assigned employee should be able to create only one environment. The unassigned +employee should be rejected by Identity Center before reaching AgentFormation. + +## Access removal + +Remove an employee from the assigned Identity Center group when their access +should end. For an immediate app-side block and stopped EC2 bill, also run: + +```bash +AWS_PROFILE=your-profile ./agentformation users disable --email person@example.com +``` + +Use `users enable` to restore a preserved runtime. Before permanently purging a +runtime, remove the employee's application assignment; otherwise that employee is +still approved by Identity Center and can create a new environment after signing +in again. + +AWS references: [customer-managed SAML application setup](https://docs.aws.amazon.com/singlesignon/latest/userguide/customermanagedapps-set-up-your-own-app-saml2.html), +[application assignments and the nested-group limitation](https://docs.aws.amazon.com/singlesignon/latest/userguide/assignuserstoapp.html), +[application attribute mappings](https://docs.aws.amazon.com/singlesignon/latest/userguide/mapawsssoattributestoapp.html), and +[Cognito SAML identity providers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html), including +[why a metadata URL is preferred](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managing-saml-idp.html). diff --git a/docs/maintainer-release-checklist.md b/docs/maintainer-release-checklist.md index b2cfd7f..f725d3c 100644 --- a/docs/maintainer-release-checklist.md +++ b/docs/maintainer-release-checklist.md @@ -1,30 +1,80 @@ # Maintainer release checklist Public pull-request checks intentionally do not receive AWS credentials. Before a -release that changes infrastructure, authentication, runtime images, or terminal -behavior, a maintainer should use a dedicated test AWS account and complete this -checklist. +release that changes infrastructure, authentication, runtime images, environment +creation, or terminal behavior, a maintainer should use a dedicated test AWS +account and complete this checklist. -- Run all local and CI checks from `CONTRIBUTING.md`. -- Confirm the configured Claude Code and Codex versions still exist at their - official distribution sources. -- Deploy from a clean checkout with `./agentformation deploy`. +- Run `./scripts/check.sh` from a clean checkout and confirm the dependency audit + reports no known vulnerabilities. +- Run the repository secret and static-security scans described in the release + handoff. +- Confirm the configured AWS CLI, Claude Code, and Codex versions still exist at + their official distribution sources. Refresh installer hashes only after + reviewing the downloaded scripts. +- Leave both `identityCenter.metadataUrl` and `identityCenter.metadataFile` empty + and run `./agentformation deploy`. Confirm it creates only the identity + bootstrap, prints the SAML ACS URL and audience, and stops before publishing a + web service. +- Create a customer-managed IAM Identity Center SAML application in the test + organization, map `Subject` to `${user:subject}` with the `persistent` format, + map `email` to `${user:email}` with the `unspecified` format, and assign one + dedicated test group. Add the test user directly; nested-group membership does + not satisfy an application assignment. +- Copy the IAM Identity Center metadata HTTPS address into the ignored + `agentformation.local.json`, set `identityCenter.metadataUrl`, and complete + `./agentformation deploy`. Confirm the address is absent from Git diffs and + command output. +- When updating a deployment created by an older AgentFormation release, confirm + the deploy preserves the existing Cognito username case-sensitivity setting + instead of attempting to replace the user pool. +- Test the downloaded-XML fallback at least once before a release that changes + metadata handling: leave `metadataUrl` empty, put the XML in the ignored + `.agentformation/` directory, set `identityCenter.metadataFile`, and rerun the + doctor and deployment. - Confirm the first build warning is accurate and record total deployment time. -- Sign in as a real invited Cognito user. +- Sign in as a real assigned Identity Center user. Confirm AgentFormation asks for + no separate username, password, or MFA setup. +- For a newly assigned app, sign out of the AWS access portal and sign back in so + the test does not rely on the existing session's hourly application refresh. +- With an active company SSO session, confirm the app returns without another + credential prompt. +- Confirm an unassigned Identity Center user is rejected before reaching the app. +- Choose **Create environment** and confirm one fixed setup job creates one runtime. + Press the button twice or repeat the POST and confirm no second runtime appears. - Confirm the user starts in `/workspace`. - Run `claude --version` and `codex --version`. - Make one harmless Bedrock request through each CLI. - Create a file, disconnect, reconnect, and confirm the `tmux` process and file persist. - Upload a harmless file and confirm it lands under `/workspace/.uploads`. -- Create a second Cognito test identity with `--suppress-invite` and verify the - first user's authenticated requests cannot select or access that runtime. -- Disable the test identity and confirm sign-in is blocked and EC2 is stopping. +- Sign in as a second assigned test identity and verify the first user's requests + cannot select or access the second runtime. +- Confirm cross-site and non-JSON environment, terminal, and upload requests are + rejected, API responses are not cached, and the production script policy has no + `unsafe-inline` allowance. +- Confirm the web role can start only the named setup job and cannot call + CloudFormation directly. +- Confirm the setup job rejects a nonexistent or disabled Cognito subject and can + use only the content-hashed runtime template and operator-selected settings. +- Confirm the runtime CloudFormation role cannot launch a different AMI, subnet, + security group, or untagged instance. +- Confirm the Claude runtime role can invoke its configured inference profile but + cannot invoke an unrelated foundation model directly. +- Disable the first identity and confirm app access is blocked and EC2 is stopping. +- Enable it and confirm the preserved runtime and files return. +- Remove its Identity Center group assignment and confirm a fresh sign-in is + rejected. - Purge the test identity and verify its stack and volume are gone. -- Review IAM Access Analyzer and ECR image scan findings. +- Review findings in the account or organization IAM Access Analyzer. AgentFormation + intentionally does not create this account-wide service; if no analyzer exists, + record that operator gap instead of marking the review complete. +- Confirm the ECR scan for the exact web image tag completed, then review every + reported finding before release. - Run `./agentformation destroy --confirm DELETE` after the review window and verify no tagged EC2, EBS, NAT, App Runner, ECR, S3, DynamoDB, Cognito, Secrets - Manager, Image Builder, AMI, or snapshot resources remain. + Manager, Step Functions, Image Builder, AMI, or snapshot resources remain. -Do not paste account IDs, user emails, secrets, session tokens, terminal contents, -or private resource URLs into public release notes. +Do not paste account IDs, user emails, SAML metadata addresses or XML, secrets, +session tokens, terminal contents, or private resource URLs into public release +notes. diff --git a/docs/migrating-local-agent-configs.md b/docs/migrating-local-agent-configs.md new file mode 100644 index 0000000..df27ee5 --- /dev/null +++ b/docs/migrating-local-agent-configs.md @@ -0,0 +1,195 @@ +# Migrate local Codex and Claude Code settings + +AgentFormation starts each runtime with clean Codex and Claude Code installations +configured for Amazon Bedrock. After the runtime is working, an operator can +optionally move a person's existing preferences, skills, and approved session +history from a trusted computer into that person's assigned runtime. + +This is an operator task. It requires an AWS profile that can identify the +assigned runtime, use Systems Manager, and write temporary objects to the +deployment's upload bucket. The browser user should not receive broad AWS access +just to migrate files. + +## Recommended way + +Start Codex from the root of this repository so it discovers the repo skill, then +ask: + +```text +Use $migrate-agent-configs to move my local Codex and Claude Code settings to my +assigned AgentFormation runtime. Start with a read-only inventory. Do not copy +credentials or session history until I approve those categories separately. +``` + +The skill is stored at +[`../.agents/skills/migrate-agent-configs/SKILL.md`](../.agents/skills/migrate-agent-configs/SKILL.md). +It verifies the exact user-to-instance assignment, stages only encrypted data, +uses AWS Systems Manager to perform the remote work, validates both tools, and +removes the temporary local, S3, and remote files. + +## Inventory everything before moving anything + +Treat these as separate choices. The inventory should compare local and remote +names, sizes, versions, hashes, and database row counts without printing file +contents or secret values: + +| Category | Typical contents | Default | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | +| Codex settings | `config.toml`, status line, model and approval preferences, global `AGENTS.md`, rules, hooks, personal skills, intentional plugins, and portable MCP definitions | Review and migrate | +| Codex history | `sessions/`, `archived_sessions/`, prompt history, session names, thread indexes, memories, and goals | Ask first; it may contain sensitive work | +| Claude Code settings | `settings.json`, user instructions, hooks, skills, and portable MCP definitions | Review and migrate | +| Claude Code history | approved project and conversation history | Ask first; it may contain sensitive work | +| Codex ChatGPT login | `~/.codex/auth.json` when file-based login is enabled | Do not copy unless the person wants OpenAI instead of Bedrock | +| Claude login | Claude account or operating-system credential state | Prefer a fresh supported login; Bedrock needs none | +| MCP and GitHub logins | OAuth tokens, keychain records, or CLI credentials | Prefer fresh device/OAuth login on the runtime | +| Source repositories | committed code and optional uncommitted work | Prefer a fresh clone; ask separately about uncommitted files | +| Disposable state | logs, caches, sockets, locks, temporary files, process state, downloaded runtimes, and compiled plugin binaries | Do not copy | + +Do not mistake a matching directory size for a completed migration. Check each +portable category, preserve newer remote-only files, and verify the behavior the +person expects after installation. For example, confirm the status line from the +remote config and prove that one approved old thread is both present and indexed. + +The public AgentFormation defaults do not need personal OpenAI or Anthropic +credentials. Codex and Claude Code use the EC2 runtime's AWS role to call Bedrock. +Changing one runtime to a personal provider is possible, but it is a deliberate +per-user override and should not change the public template. + +## How the protected transfer works + +Systems Manager provides the authenticated remote channel, but AWS warns against +putting secrets directly in Run Command parameters because command history is +retained. The migration therefore uses this pattern: + +```text +approved local files + | + v +allowlisted archive -- client-side encryption --> encrypted S3 objects + | + assigned runtime reads only its prefix + | + v +SSM command --> verify, decrypt in private temp dir, install, validate, clean up +``` + +The destination creates a one-time key pair and keeps its private key on the +runtime. The source encrypts the archive and wraps its random archive secret with +the destination's public key. Only ciphertext is uploaded. After validation, the +operator deletes the exact S3 objects and both machines delete their temporary +key and archive files. + +Remote files are backed up before replacement. Credential files are owned by the +`agentformation` user, readable only by that user, and never committed to this +repository. + +## Portability checks + +- Rewrite paths such as `/Users/name/...` to Linux paths under + `/home/agentformation` or `/workspace`. +- Disable local-only MCP servers and tools instead of marking them required and + preventing the CLI from starting. +- Keep Linux-installed system skills and plugin packages when the runtime uses a + newer Codex version. Move the person's selections and portable custom content, + not a macOS plugin cache or bundled binary. +- Merge session files and line-oriented history when the runtime already has new + chats. Do not replace the remote history database with an older local copy. +- Do not copy macOS Keychain data, sockets, process state, caches, logs, + `node_modules`, virtual environments, or machine-specific binaries. +- Back up live SQLite databases with SQLite's backup operation before archiving + them. +- On macOS, create the archive with Apple metadata disabled. Reject AppleDouble + files such as `._payload`, absolute paths, parent-directory paths, symlinks, + and every entry outside the approved allowlist before extraction. +- Preserve the original Bedrock config whenever a user intentionally switches a + runtime to a personal OpenAI or Anthropic login. + +## Find migrated Codex chats + +Codex filters its normal resume picker to chats whose indexed working folder +matches the current folder. Local chats commonly record a path such as +`/Users/name/project`, while AgentFormation starts in `/workspace`. Copying the +chat files without adapting that index makes a migration look successful while +ordinary `codex resume` and the in-app `/resume` command show no chats. + +When approved history is installed, keep an SQLite backup. Map each copied +rollout path from the source Codex `sessions/` or `archived_sessions/` directory +to its matching directory beneath `/home/agentformation/.codex/`, and map copied, +unarchived user-chat working folders to `/workspace`. Verify that every indexed +rollout is a regular file beneath one of those remote directories. Do not rewrite +message content. Keep the original paths in the encrypted transfer manifest or +restricted rollback copy, and set this portable runtime preference: + +```toml +[tui] +resume_cwd = "current" +``` + +Then verify the ordinary picker first: + +```bash +codex resume -C /workspace +``` + +It must show the migrated interactive chats. Also verify the wider picker: + +```bash +codex resume --all --include-non-interactive -C /workspace +``` + +`--all` disables the folder filter, `--include-non-interactive` requests chats +created by non-interactive Codex commands, and `-C /workspace` gives the resumed +chat a real Linux folder. Finally, resume one approved migrated chat directly by +its ID. File counts alone are not proof that the picker or the chat can read it. +A configured status line appears after Codex enters a conversation; it is not +shown on every startup or trust screen. + +## Remote OAuth callbacks + +Some MCP clients open a browser and then redirect to a URL beginning with +`http://127.0.0.1:/callback` or +`http://localhost:/callback`. In a remote browser terminal, that address +belongs to the private runtime, not the laptop running the browser. + +The browser will normally show `ERR_CONNECTION_REFUSED`; that is expected because +its `127.0.0.1` is the user's device rather than the remote runtime. Copy the +complete URL from the failed page's address bar, return to the still-open +AgentFormation tab, choose **Finish login**, and paste it into **Complete remote +login** while the CLI is still waiting. AgentFormation validates that the +destination is strictly local and sends the one-time callback only to the +signed-in person's assigned runtime. + +See the [remote CLI sign-in guide](remote-cli-login.md) for the complete user +flow, safe handling rules, and troubleshooting steps. + +## Validate and revoke + +After migration: + +1. Compare the remote status line, global instructions, hooks, rules, personal + skills, plugin selections, and MCP names with the approved local inventory. +2. Confirm `codex login status` shows the intended provider or login method. +3. Confirm `codex mcp list` succeeds and required MCP servers do not block Codex + startup. +4. Run `codex resume -C /workspace`, confirm migrated interactive chats appear, + then run `codex resume --all --include-non-interactive -C /workspace` and + directly resume one approved old session by ID when history was migrated. +5. Start Claude Code and use `/status` to confirm its settings and provider. +6. Confirm newer remote-only chats remain present and the rollback directory is + private and readable only by the runtime user. +7. Confirm the exact temporary S3 prefix and local and remote scratch directories + are empty or gone. + +Do not report the migration complete merely because files were uploaded. Report +it only after the remote behavior, retained remote-only state, rollback copy, and +cleanup checks all pass. + +Run `codex logout` or the provider's normal revocation flow on the remote runtime +to remove a copied personal Codex login. Logging out on the source computer does +not delete a separate remote credential cache. + +For current vendor details, see the official +[Codex authentication guide](https://learn.chatgpt.com/docs/auth), +[Codex skill guide](https://learn.chatgpt.com/docs/build-skills), +[Claude Code settings guide](https://code.claude.com/docs/en/settings), and +[AWS warning about secrets in Run Command](https://docs.aws.amazon.com/systems-manager/latest/userguide/running-commands.html). diff --git a/docs/privacy.md b/docs/privacy.md index d3f0216..a1d0862 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -1,16 +1,25 @@ # Privacy notes -AgentFormation is self-hosted in the operator's AWS account. The project itself -does not run a hosted service or receive deployment data. +AgentFormation is self-hosted in the operator's AWS account. The open-source +project does not run a hosted service or receive deployment data. -The deployed system stores: +The deployed system stores or processes: -- invited users' email addresses and Cognito identifiers; -- the mapping between a user and an EC2 runtime; +- assigned employees' email addresses and federated Cognito identifiers; +- the mapping between an employee and an EC2 runtime; +- environment-creation status and AWS Step Functions execution history; - encrypted runtime files on EBS; - uploaded files in S3 until copied or expired; and - normal AWS service, access, build, and application logs. +The IAM Identity Center metadata address or downloaded XML contains +organization-specific SAML endpoints and public signing certificates. The +address belongs only in ignored `agentformation.local.json`; a fallback XML file +belongs in the ignored `.agentformation/` directory. Neither should be committed +or posted publicly, even though the signing certificate is public and no private +signing key is included. The address is preferred because Cognito can refresh +updated metadata automatically. + Terminal traffic uses AWS Systems Manager. Prompts and code sent to Claude Code or Codex are processed through Amazon Bedrock under the operator's AWS agreement and configuration. Git providers, package registries, and any tools a user runs may diff --git a/docs/remote-cli-login.md b/docs/remote-cli-login.md new file mode 100644 index 0000000..c1b762b --- /dev/null +++ b/docs/remote-cli-login.md @@ -0,0 +1,61 @@ +# Finish a remote command-line login + +Codex, Claude Code, and MCP servers may open a browser to authorize a connection. +Some of those flows finish at a temporary address such as: + +```text +http://127.0.0.1:36557/callback/request-id?code=...&state=... +``` + +The browser will normally show `ERR_CONNECTION_REFUSED`. This is expected. The +browser's `127.0.0.1` means the employee's laptop or phone, while the process +waiting for the callback is inside the private AgentFormation runtime. + +## Complete the login + +1. Keep the AgentFormation tab open. +2. Start the login from the command-line tool in the remote terminal. +3. Complete the provider's sign-in and approval in the new browser tab. +4. On the `ERR_CONNECTION_REFUSED` page, copy the complete address from the + browser address bar. Do not copy only the visible error text. +5. Return to the AgentFormation tab and choose **Finish login** in the page + header. +6. Paste the complete failed address into **Complete remote login**. +7. Choose **Send to runtime** while the command-line tool is still waiting. +8. When AgentFormation reports **Callback delivered**, return to the terminal and + confirm the tool completed sign-in. + +The failed page is part of this flow; AgentFormation does not make the laptop's +localhost listener work. Instead, it safely delivers that one callback from +inside the employee's assigned runtime. + +## Handle the address like a password + +The callback address contains a short-lived, one-time authorization code: + +- paste it only into the signed-in deployment's **Finish login** form; +- do not paste it into chat, tickets, screenshots, shell history, or logs; +- do not reuse an address from an earlier login attempt; and +- close the failed callback tab after AgentFormation delivers it. + +AgentFormation accepts only plain HTTP callbacks whose host is exactly +`localhost` or `127.0.0.1`, whose port is numeric, and whose path is `/callback` +or `/callback/`. It rejects outside hosts and unsafe paths. The +one-time value is briefly staged under the signed-in subject's encrypted upload +prefix, kept out of Systems Manager command history, and deleted after delivery. + +## If it does not finish + +- **AgentFormation says Callback delivered, but the CLI is still waiting:** the + listener probably expired. Cancel that login, start a fresh one, and deliver + the newly generated address. +- **The form says the callback address is invalid:** copy the complete address + from the browser address bar, including the port, path, and query string. +- **The form says the runtime command failed:** confirm the runtime is connected, + keep the CLI waiting, and retry with a fresh login attempt. +- **The browser shows ERR_CONNECTION_REFUSED:** continue with the numbered steps + above; that browser error by itself is expected. + +When asking an administrator for help, share the displayed AgentFormation error +message and approximate time. Do not share the callback address or its `code=` +value. diff --git a/docs/security-model.md b/docs/security-model.md index ae3e93e..9ed2568 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -2,37 +2,106 @@ ## Trust boundaries -AgentFormation has two different administrator levels: +AgentFormation has three important trust levels: -- An invited app user is trusted only inside that user's assigned runtime. -- The AWS account administrator is fully trusted. AWS administrators can use IAM, - Systems Manager, EC2, snapshots, logs, or CloudFormation to access or modify any - runtime. +- An employee assigned to the IAM Identity Center application is trusted only + inside that employee's own runtime. +- The App Runner service and its AWS role are trusted across this AgentFormation + deployment. A compromise there can affect managed runtimes, as described below. +- The AWS account administrator is fully trusted. Administrators can use IAM, + Systems Manager, EC2, snapshots, logs, or CloudFormation to inspect or change + any runtime. AgentFormation does not claim to protect runtime data from the AWS account owner. -## User isolation +## Company sign-in -Self-sign-up is disabled. An administrator creates a Cognito user and a dedicated -runtime. DynamoDB stores the Cognito `sub`, email address, runtime stack, instance -ID, status, and update time. +IAM Identity Center application assignment is the front door. Operators should +normally assign a dedicated AgentFormation access group and remove employees from +that group when access should end. Members must be added directly because Identity +Center application assignments do not support nested groups. Reusing a broader +employee or developer group is appropriate only when every direct member should be +allowed to create a runtime and incur its AWS cost. The AWS root user is a separate +emergency identity and is not a supported app login. An AWS permission set such as +PowerUser is also not a login; it can be associated with the same employees, but +AgentFormation relies only on the separate application assignment. + +Amazon Cognito is an invisible SAML-to-OIDC bridge. The web app sends users +straight to the `IdentityCenter` SAML provider. The app client excludes Cognito +local sign-in after setup, self-sign-up is unavailable, and AgentFormation stores +no employee password or separate MFA setting. The organization's existing sign-in +and MFA policy remains in control. + +The operator supplies Cognito with either the IAM Identity Center metadata HTTPS +address or a downloaded metadata XML file. The address is preferred because +Cognito refreshes signing metadata automatically. Both forms are +organization-specific deployment configuration and stay outside Git. + +Identity Center emits an employee email. Cognito creates a stable federated `sub` +identifier, which becomes the runtime authorization key. Email is kept for display +and administration, but browser requests cannot use an email address to select a +runtime. + +## Runtime isolation For every terminal or upload request, the server: 1. verifies the Auth.js session; -2. reads the Cognito subject from that session; +2. reads the federated Cognito subject from that session; 3. retrieves the matching DynamoDB record; 4. requires the record to be active; and 5. targets only the instance in that record. The client never submits an instance ID. Runtime IDs are not returned by the -session-start API. Browser termination requests also carry an HMAC proof binding -the Systems Manager session ID to the Cognito subject. - -The App Runner role can reach all AgentFormation-tagged runtimes, so a compromise -of the web service role or server application can affect every managed runtime in -that deployment. Keep dependencies current and restrict who can change the web -image and CloudFormation stacks. +session-start API. Browser termination requests also carry an HMAC proof that +binds the Systems Manager session ID to the signed-in subject. + +State-changing environment, terminal, upload, and OAuth-relay routes accept only +same-origin JSON requests. Their success and error responses disable browser and +intermediary caching. The web app uses a new script nonce for every request instead +of allowing arbitrary inline scripts in its Content Security Policy. + +The OAuth relay exists for command-line tools that listen for a one-time callback +inside the private runtime. It accepts only plain HTTP URLs whose host is exactly +`localhost` or `127.0.0.1`, whose port is numeric, and whose path is `/callback` or +`/callback/` with a URL-safe request ID. It rejects credentials, +fragments, nested paths, and non-local hosts, normalizes the destination to +`127.0.0.1`, and delivers the callback through Systems Manager only to the runtime +mapped from the signed-in federated subject. + +The callback value is staged briefly in the encrypted, one-day upload bucket +under that subject's private prefix. Systems Manager command history contains +only the random object reference, not the OAuth code. The runtime reads the +object with its own restricted role, stores it in a private temporary file, and +removes that file after the request. The web service deletes the staging object +after the command; the bucket lifecycle is a cleanup backstop. + +## Self-service environment creation + +A signed-in employee can ask the App Runner service to start one fixed Step +Functions job. The web role can start that exact job; it cannot call +CloudFormation itself. + +The job: + +1. confirms that the Cognito subject identifies exactly one enabled federated + user in this user pool; +2. conditionally reserves one DynamoDB record for that subject; +3. uses an opaque, deterministic runtime stack name derived from the subject; +4. creates only the reviewed runtime template uploaded under its SHA-256 content + hash; +5. supplies network, AMI, instance size, volume, model, and upload settings chosen + by the operator during deployment; and +6. records the resulting tagged EC2 instance only after CloudFormation succeeds. + +A separate CloudFormation service role can create only the named AgentFormation +runtime IAM roles, the operator-selected AMI/network dependencies, and tagged +runtime instances. The setup job cannot accept a browser-provided template URL, +AMI, subnet, security group, IAM policy, model, or instance size. + +The DynamoDB conditional write makes repeated button presses idempotent: an active +or in-progress employee record is not replaced. A failed setup can be retried +using the same reviewed stack name. ## Network and host controls @@ -41,6 +110,7 @@ image and CloudFormation stacks. - Browser terminals use AWS Systems Manager Session Manager; SSH is not opened. - EC2 instance metadata requires IMDSv2. - Runtime EBS volumes and staged uploads are encrypted at rest. +- The upload bucket rejects requests that do not use HTTPS. - Upload objects expire after one day and are deleted after they reach a runtime. - The default network uses one NAT gateway. Optional AWS service endpoints reduce some traffic through that gateway but do not remove the need for internet access @@ -52,11 +122,10 @@ The runtime uses its EC2 instance role. Long-lived AWS access keys are not place on disk by the template. Claude Code and Codex use the normal AWS credential chain and are configured for Amazon Bedrock. -Claude Code uses Bedrock's native runtime endpoint. Its runtime role allows -foundation-model invocation plus account inference profiles because cross-region -profiles can route to more than one underlying model ARN. Organizations that -require a strict Claude allowlist should narrow these resources to their approved -model and profile ARNs. +Claude Code uses Bedrock's native runtime endpoint. Before each runtime deployment, +the installer resolves the configured inference profile and its current destination +model ARNs. The runtime role can invoke only that profile and those foundation +models, and the foundation models can be invoked only through that profile. Codex uses Bedrock's OpenAI-compatible Mantle endpoint. Its inference permission is restricted to the configured Codex model and the account's `default` Bedrock @@ -68,24 +137,49 @@ broader AWS-managed policy. Bedrock access, provider terms, quotas, and regional model availability are controlled separately by AWS. +The runtime image pins the AWS CLI, Claude Code, and Codex versions. The AWS and +Codex installer scripts are checksum-checked before execution. AWS's installer +then verifies the AWS CLI package signature with its embedded AWS CLI team key; +the Codex installer verifies the selected release archive digest. + ## User lifecycle -- `users add` creates or reuses a Cognito identity, deploys one runtime, then writes - the active assignment. -- `users disable` blocks Cognito sign-in first, marks the assignment disabled, and - stops EC2 while preserving the encrypted disk. -- `users purge --confirm DELETE` removes the identity, CloudFormation runtime, and - persistent disk. +- Assigning an Identity Center group allows its members to sign in. Each member can + create one runtime from the fixed setup job. +- `users disable` disables the federated Cognito profile, marks the registry record + disabled, and stops EC2 while preserving its encrypted disk. +- `users enable` starts the preserved instance, re-enables the profile, and restores + the active registry record. +- `users purge --confirm DELETE` deletes the federated profile, CloudFormation + runtime, and persistent disk. + +Remove the Identity Center application or group assignment as the source-of-truth +offboarding step. If an assigned employee is purged but remains assigned, their +next valid company sign-in can create a new federated profile and runtime. + +An already-open Systems Manager terminal may remain usable briefly while disable +or group removal propagates. Use `users disable` and terminate active Systems +Manager sessions when immediate eviction is required. + +## Compromise scope + +The App Runner role can start the fixed setup job for an enabled federated subject +and can manage terminal sessions and staged uploads across all tagged runtimes in +this deployment. A compromise of the service role or server application can +therefore affect every managed runtime. It still cannot submit an arbitrary +CloudFormation template or choose more privileged runtime settings. -An already-issued terminal session may remain usable briefly after a user is -disabled. Terminate active Systems Manager sessions if immediate eviction is -required. +Keep dependencies current, restrict who can publish the web image or update the +stacks, and monitor the account using the organization's normal AWS controls. ## Known limits - This is a reference project, not a formally audited security product. - The web terminal depends on an npm package that implements the Session Manager browser protocol. Review dependency changes carefully. +- The template does not enable an account-wide web firewall, VPC flow logs, + CloudTrail trail, GuardDuty, or Security Hub. Operators should apply their + account's monitoring and retention policy separately. - No backup or disaster-recovery policy is created for runtime EBS volumes. - A single availability zone and NAT gateway keep the template understandable but are not a high-availability design. diff --git a/scripts/check.sh b/scripts/check.sh index 0666cfe..d2b592d 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -16,7 +16,10 @@ bun run test AUTH_SECRET=build-only-secret-with-at-least-32-characters \ AUTH_COGNITO_ID=build-client \ AUTH_COGNITO_SECRET=build-secret \ +AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter \ AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build \ +AGENTFORMATION_DEPLOYMENT=agentformation \ +PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:build-only \ AWS_REGION=us-east-1 \ USER_REGISTRY_TABLE=build-users \ UPLOAD_BUCKET=build-uploads \ diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 7c8e34f..3d683f5 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -12,8 +12,72 @@ ensure_state_dir DEPLOYMENT="$(deployment_name)" ACCOUNT_ID="$(aws_cli sts get-caller-identity --query Account --output text)" +CALLER_ARN="$(aws_cli sts get-caller-identity --query Arn --output text)" +AWS_PARTITION="$(cut -d: -f2 <<<"$CALLER_ARN")" +if [[ "$AWS_PARTITION" == "aws-cn" ]]; then + AWS_URL_SUFFIX="amazonaws.com.cn" +else + AWS_URL_SUFFIX="amazonaws.com" +fi DOMAIN_SUFFIX="$(hash_text "$ACCOUNT_ID:$DEPLOYMENT" | cut -c1-10)" DOMAIN_PREFIX="$DEPLOYMENT-$DOMAIN_SUFFIX" +IDENTITY_CENTER_METADATA_URL="$(config '.identityCenter.metadataUrl // ""')" +IDENTITY_CENTER_METADATA_FILE="$(config '.identityCenter.metadataFile // ""')" +CONFIGURED_PUBLIC_URL="$(config '(.publicUrl // "") | rtrimstr("/")')" +if [[ -n "$IDENTITY_CENTER_METADATA_FILE" && "$IDENTITY_CENTER_METADATA_FILE" != /* ]]; then + IDENTITY_CENTER_METADATA_FILE="$ROOT_DIR/$IDENTITY_CENTER_METADATA_FILE" +fi + +CONFIGURE_CASE_INSENSITIVE_USERNAMES=true +if aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/dev/null 2>&1; then + EXISTING_USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" + EXISTING_USERNAME_CASE_SENSITIVE="$(aws_cli cognito-idp describe-user-pool \ + --user-pool-id "$EXISTING_USER_POOL_ID" \ + --query 'UserPool.UsernameConfiguration.CaseSensitive' \ + --output text)" + case "$EXISTING_USERNAME_CASE_SENSITIVE" in + False | false) + CONFIGURE_CASE_INSENSITIVE_USERNAMES=true + ;; + True | true | None | none | null | '') + # Older pools can omit this immutable property; Cognito then keeps its + # original case-sensitive default. Preserve it during an upgrade. + CONFIGURE_CASE_INSENSITIVE_USERNAMES=false + ;; + *) + fail "Could not determine the existing Cognito username case-sensitivity setting" + ;; + esac +fi + +print_identity_center_setup() { + local acs_url audience user_pool_id outputs cognito_domain_suffix + outputs="$(aws_cli cloudformation describe-stacks \ + --stack-name "$(foundation_stack)" \ + --query 'Stacks[0].Outputs' \ + --output json)" + acs_url="$(jq -r '[.[] | select(.OutputKey == "CognitoSamlAcsUrl") | .OutputValue][0] // ""' <<<"$outputs")" + audience="$(jq -r '[.[] | select(.OutputKey == "CognitoSamlAudience") | .OutputValue][0] // ""' <<<"$outputs")" + if [[ ! "$acs_url" =~ ^https:// ]] || [[ "$audience" != urn:amazon:cognito:sp:* ]]; then + user_pool_id="$(jq -er '[.[] | select(.OutputKey == "UserPoolId") | .OutputValue][0]' <<<"$outputs")" + if [[ "$AWS_PARTITION" == "aws-cn" ]]; then + cognito_domain_suffix="amazoncognito.com.cn" + else + cognito_domain_suffix="amazoncognito.com" + fi + acs_url="https://$DOMAIN_PREFIX.auth.$(region).$cognito_domain_suffix/saml2/idpresponse" + audience="urn:amazon:cognito:sp:$user_pool_id" + fi + say "SAML ACS URL: $acs_url" + say "SAML audience: $audience" +} + +if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" ]] && \ + aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/dev/null 2>&1; then + say "Identity Center setup is required before this existing deployment can be updated" + print_identity_center_setup + fail "Assign an IAM Identity Center group to a custom SAML application, then set identityCenter.metadataUrl (preferred) or identityCenter.metadataFile" +fi deploy_stack "$(network_stack)" templates/network.yaml \ DeploymentName="$DEPLOYMENT" \ @@ -21,12 +85,90 @@ deploy_stack "$(network_stack)" templates/network.yaml \ deploy_stack "$(foundation_stack)" templates/foundation.yaml \ DeploymentName="$DEPLOYMENT" \ - CognitoDomainPrefix="$DOMAIN_PREFIX" + CognitoDomainPrefix="$DOMAIN_PREFIX" \ + ConfigureCaseInsensitiveUsernames="$CONFIGURE_CASE_INSENSITIVE_USERNAMES" + +if [[ -z "$IDENTITY_CENTER_METADATA_URL" && -z "$IDENTITY_CENTER_METADATA_FILE" ]]; then + say "The identity bootstrap is ready" + print_identity_center_setup + fail "Create and assign the IAM Identity Center SAML application, set identityCenter.metadataUrl (preferred) or identityCenter.metadataFile, and run deploy again" +fi USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" CLIENT_ID="$(stack_output "$(foundation_stack)" UserPoolClientId)" CLIENT_SECRET_ARN="$(stack_output "$(foundation_stack)" CognitoClientSecretArn)" +configure_cognito_client() { + local callback_urls_json="$1" + local logout_urls_json="$2" + local url + local -a callback_urls=() + local -a logout_urls=() + while IFS= read -r url; do + [[ -n "$url" ]] && callback_urls+=("$url") + done < <(jq -r '.[]' <<<"$callback_urls_json") + while IFS= read -r url; do + [[ -n "$url" ]] && logout_urls+=("$url") + done < <(jq -r '.[]' <<<"$logout_urls_json") + [[ "${#callback_urls[@]}" -gt 0 && "${#logout_urls[@]}" -gt 0 ]] || \ + fail "Cognito callback and logout URLs cannot be empty" + + aws_cli cognito-idp update-user-pool-client \ + --user-pool-id "$USER_POOL_ID" \ + --client-id "$CLIENT_ID" \ + --supported-identity-providers IdentityCenter \ + --explicit-auth-flows ALLOW_REFRESH_TOKEN_AUTH \ + --allowed-o-auth-flows code \ + --allowed-o-auth-scopes openid email profile \ + --allowed-o-auth-flows-user-pool-client \ + --callback-urls "${callback_urls[@]}" \ + --logout-urls "${logout_urls[@]}" \ + --prevent-user-existence-errors ENABLED \ + --enable-token-revocation \ + --access-token-validity 60 \ + --id-token-validity 60 \ + --refresh-token-validity 1 \ + --token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=days >/dev/null +} + +say "Connecting the Cognito bridge to IAM Identity Center" +if [[ -n "$IDENTITY_CENTER_METADATA_URL" ]]; then + IDENTITY_CENTER_PROVIDER_DETAILS="$(jq -cn \ + --arg metadataUrl "$IDENTITY_CENTER_METADATA_URL" \ + '{MetadataURL:$metadataUrl,IDPInit:"false",IDPSignout:"false",RequestSigningAlgorithm:"rsa-sha256"}')" +else + IDENTITY_CENTER_PROVIDER_DETAILS="$(jq -cn \ + --rawfile metadata "$IDENTITY_CENTER_METADATA_FILE" \ + '{MetadataFile:$metadata,IDPInit:"false",IDPSignout:"false",RequestSigningAlgorithm:"rsa-sha256"}')" +fi +if aws_cli cognito-idp describe-identity-provider \ + --user-pool-id "$USER_POOL_ID" \ + --provider-name IdentityCenter >/dev/null 2>&1; then + aws_cli cognito-idp update-identity-provider \ + --user-pool-id "$USER_POOL_ID" \ + --provider-name IdentityCenter \ + --provider-details "$IDENTITY_CENTER_PROVIDER_DETAILS" \ + --attribute-mapping email=email \ + --idp-identifiers identity-center >/dev/null +else + aws_cli cognito-idp create-identity-provider \ + --user-pool-id "$USER_POOL_ID" \ + --provider-name IdentityCenter \ + --provider-type SAML \ + --provider-details "$IDENTITY_CENTER_PROVIDER_DETAILS" \ + --attribute-mapping email=email \ + --idp-identifiers identity-center >/dev/null +fi + +CURRENT_CLIENT_URLS="$(aws_cli cognito-idp describe-user-pool-client \ + --user-pool-id "$USER_POOL_ID" \ + --client-id "$CLIENT_ID" \ + --query 'UserPoolClient.{callbacks:CallbackURLs,logouts:LogoutURLs}' \ + --output json)" +configure_cognito_client \ + "$(jq -c '.callbacks' <<<"$CURRENT_CLIENT_URLS")" \ + "$(jq -c '.logouts' <<<"$CURRENT_CLIENT_URLS")" + say "Storing the generated Cognito client secret without printing it" aws_cli cognito-idp describe-user-pool-client \ --user-pool-id "$USER_POOL_ID" \ @@ -38,10 +180,11 @@ aws_cli cognito-idp describe-user-pool-client \ --secret-string file:///dev/stdin >/dev/null ARCHITECTURE="$(config '.runtime.architecture')" +AWS_CLI_VERSION="$(config '.versions.awsCli')" CLAUDE_CODE_VERSION="$(config '.versions.claudeCode')" CODEX_VERSION="$(config '.versions.codex')" IMAGE_TEMPLATE_HASH="$(hash_text "$(<"$ROOT_DIR/templates/image.yaml")")" -IMAGE_COMPONENT_HASH="$(hash_text "$ARCHITECTURE:$CLAUDE_CODE_VERSION:$CODEX_VERSION:$IMAGE_TEMPLATE_HASH")" +IMAGE_COMPONENT_HASH="$(hash_text "$ARCHITECTURE:$AWS_CLI_VERSION:$CLAUDE_CODE_VERSION:$CODEX_VERSION:$IMAGE_TEMPLATE_HASH")" IMAGE_COMPONENT_VERSION="1.0.$((16#${IMAGE_COMPONENT_HASH:0:7}))" if [[ "$ARCHITECTURE" == "arm64" ]]; then BUILD_INSTANCE="c7g.large" @@ -54,6 +197,7 @@ deploy_stack "$(image_stack)" templates/image.yaml \ NetworkStackName="$(network_stack)" \ Architecture="$ARCHITECTURE" \ BuildInstanceType="$BUILD_INSTANCE" \ + AwsCliVersion="$AWS_CLI_VERSION" \ ClaudeCodeVersion="$CLAUDE_CODE_VERSION" \ CodexVersion="$CODEX_VERSION" \ AmiParameterPath="$(ami_parameter_path)" \ @@ -106,6 +250,42 @@ aws_cli ssm put-parameter \ --value "$AMI_ID" \ --overwrite >/dev/null +UPLOAD_BUCKET="$(stack_output "$(foundation_stack)" UploadBucketName)" +RUNTIME_TEMPLATE_HASH="$(hash_text "$(<"$ROOT_DIR/templates/runtime.yaml")")" +RUNTIME_TEMPLATE_KEY="provisioning/runtime-$RUNTIME_TEMPLATE_HASH.yaml" +RUNTIME_TEMPLATE_URL="https://$UPLOAD_BUCKET.s3.$(region).$AWS_URL_SUFFIX/$RUNTIME_TEMPLATE_KEY" +say "Publishing the reviewed runtime template under its content hash" +aws_cli s3 cp \ + "$ROOT_DIR/templates/runtime.yaml" \ + "s3://$UPLOAD_BUCKET/$RUNTIME_TEMPLATE_KEY" \ + --sse AES256 >/dev/null + +CLAUDE_MODEL="$(config '.models.claude')" +CLAUDE_PROFILE="$(aws_cli bedrock get-inference-profile \ + --inference-profile-identifier "$CLAUDE_MODEL" \ + --output json)" || fail "The configured Claude inference profile is unavailable" +CLAUDE_PROFILE_ARN="$(jq -er '.inferenceProfileArn' <<<"$CLAUDE_PROFILE")" +CLAUDE_MODEL_ARNS="$(jq -er '[.models[].modelArn] | select(length > 0) | join(",")' <<<"$CLAUDE_PROFILE")" + +deploy_stack "$(provisioning_stack)" templates/provisioning.yaml \ + DeploymentName="$DEPLOYMENT" \ + UserPoolId="$USER_POOL_ID" \ + UserRegistryTableName="$(stack_output "$(foundation_stack)" UserRegistryTableName)" \ + RuntimeTemplateUrl="$RUNTIME_TEMPLATE_URL" \ + RuntimeTemplateBucket="$UPLOAD_BUCKET" \ + RuntimeTemplateKey="$RUNTIME_TEMPLATE_KEY" \ + RuntimeSubnetId="$(stack_output "$(network_stack)" PrivateSubnetId)" \ + RuntimeSecurityGroupId="$(stack_output "$(network_stack)" RuntimeSecurityGroupId)" \ + RuntimeAmiId="$AMI_ID" \ + InstanceType="$(config '.runtime.instanceType')" \ + Architecture="$ARCHITECTURE" \ + VolumeSizeGiB="$(config '.runtime.volumeSizeGiB')" \ + ClaudeModelId="$CLAUDE_MODEL" \ + ClaudeInferenceProfileArn="$CLAUDE_PROFILE_ARN" \ + ClaudeFoundationModelArns="$CLAUDE_MODEL_ARNS" \ + CodexModelId="$(config '.models.codex')" \ + UploadBucketName="$UPLOAD_BUCKET" + REPOSITORY_URI="$(stack_output "$(foundation_stack)" WebRepositoryUri)" IMAGE_TAG="$(date -u +%Y%m%d%H%M%S)" REGISTRY_HOST="${REPOSITORY_URI%%/*}" @@ -173,43 +353,36 @@ deploy_web_stack() { AuthSecretArn="$(stack_output "$(foundation_stack)" AuthSecretArn)" \ UserRegistryTableName="$(stack_output "$(foundation_stack)" UserRegistryTableName)" \ UploadBucketName="$(stack_output "$(foundation_stack)" UploadBucketName)" \ - TerminalSessionDocumentName="$(stack_output "$(foundation_stack)" TerminalSessionDocumentName)" + TerminalSessionDocumentName="$(stack_output "$(foundation_stack)" TerminalSessionDocumentName)" \ + ProvisioningStateMachineArn="$(stack_output "$(provisioning_stack)" StateMachineArn)" } -INITIAL_SERVICE_URL="$(stack_output "$(web_stack)" ServiceUrl 2>/dev/null || true)" -if [[ ! "$INITIAL_SERVICE_URL" =~ ^https?:// ]]; then - INITIAL_SERVICE_URL='http://localhost:3000' +INITIAL_PUBLIC_URL="$CONFIGURED_PUBLIC_URL" +if [[ -z "$INITIAL_PUBLIC_URL" ]]; then + INITIAL_PUBLIC_URL="$(stack_output "$(web_stack)" ServiceUrl 2>/dev/null || true)" fi -deploy_web_stack "$INITIAL_SERVICE_URL" +if [[ ! "$INITIAL_PUBLIC_URL" =~ ^https?:// ]]; then + INITIAL_PUBLIC_URL='http://localhost:3000' +fi +deploy_web_stack "$INITIAL_PUBLIC_URL" SERVICE_URL="$(stack_output "$(web_stack)" ServiceUrl)" -if [[ "$INITIAL_SERVICE_URL" != "$SERVICE_URL" ]]; then - say "Applying the new App Runner public address to Auth.js" - deploy_web_stack "$SERVICE_URL" +PUBLIC_URL="${CONFIGURED_PUBLIC_URL:-$SERVICE_URL}" +if [[ "$INITIAL_PUBLIC_URL" != "$PUBLIC_URL" ]]; then + say "Applying the final public address to Auth.js" + deploy_web_stack "$PUBLIC_URL" fi -say "Updating Cognito callback URLs for the App Runner service" -aws_cli cognito-idp update-user-pool-client \ - --user-pool-id "$USER_POOL_ID" \ - --client-id "$CLIENT_ID" \ - --supported-identity-providers COGNITO \ - --allowed-o-auth-flows code \ - --allowed-o-auth-scopes openid email profile \ - --allowed-o-auth-flows-user-pool-client \ - --callback-urls "$SERVICE_URL/api/auth/callback/cognito" http://localhost:3000/api/auth/callback/cognito \ - --logout-urls "$SERVICE_URL/" http://localhost:3000/ \ - --prevent-user-existence-errors ENABLED \ - --enable-token-revocation \ - --access-token-validity 60 \ - --id-token-validity 60 \ - --refresh-token-validity 1 \ - --token-validity-units AccessToken=minutes,IdToken=minutes,RefreshToken=days >/dev/null +say "Updating Cognito callback URLs for the public web address" +configure_cognito_client \ + "$(jq -cn --arg publicUrl "$PUBLIC_URL" '[($publicUrl + "/api/auth/callback/cognito")]')" \ + "$(jq -cn --arg publicUrl "$PUBLIC_URL" '[($publicUrl + "/")]')" say "Restricting browser uploads to the deployed web address" -UPLOAD_CORS_CONFIGURATION="$(jq -cn --arg serviceUrl "$SERVICE_URL" '{ +UPLOAD_CORS_CONFIGURATION="$(jq -cn --arg publicUrl "$PUBLIC_URL" '{ CORSRules: [{ AllowedHeaders: ["content-type"], AllowedMethods: ["PUT"], - AllowedOrigins: [$serviceUrl, "http://localhost:3000"], + AllowedOrigins: [$publicUrl], ExposeHeaders: ["ETag"], MaxAgeSeconds: 300 }] @@ -218,14 +391,9 @@ aws_cli s3api put-bucket-cors \ --bucket "$(stack_output "$(foundation_stack)" UploadBucketName)" \ --cors-configuration "$UPLOAD_CORS_CONFIGURATION" -while IFS= read -r EMAIL; do - [[ -n "$EMAIL" ]] || continue - "$SCRIPT_DIR/users-add.sh" --email "$EMAIL" -done < <(config '.users[].email') - cat >"$STATE_DIR/deployment.json" </dev/null 2>&1; then - TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" - while IFS= read -r RUNTIME_STACK; do - [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]] || continue - delete_stack "$RUNTIME_STACK" - done < <(aws_cli dynamodb scan --table-name "$TABLE_NAME" --projection-expression runtimeStackName --query 'Items[].runtimeStackName.S' --output text | tr '\t' '\n') -fi +RUNTIME_STACKS="$(aws_cli cloudformation describe-stacks --query 'Stacks[].StackName' --output json)" +while IFS= read -r RUNTIME_STACK; do + [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]] || continue + delete_stack "$RUNTIME_STACK" +done < <(jq -r --arg prefix "$(deployment_name)-runtime-" '.[] | select(startswith($prefix))' <<<"$RUNTIME_STACKS") + +delete_stack "$(provisioning_stack)" if aws_cli cloudformation describe-stacks --stack-name "$(image_stack)" >/dev/null 2>&1; then PIPELINE_ARN="$(stack_output "$(image_stack)" ImagePipelineArn)" diff --git a/scripts/doctor.sh b/scripts/doctor.sh index ea5d4c0..e8d027c 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -10,18 +10,35 @@ require_config say "Checking local configuration" config '.deploymentName | test("^[a-z][a-z0-9-]{2,31}$")' >/dev/null +config '(.publicUrl // "") | type == "string" and (. == "" or test("^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?$"))' >/dev/null config '.networkMode == "private-nat" or .networkMode == "private-endpoints"' >/dev/null +config '(.identityCenter.metadataUrl // "") | type == "string" and (. == "" or test("^https://[^[:space:]]+$"))' >/dev/null +config '(.identityCenter.metadataFile // "") | type == "string"' >/dev/null config '(.cloudFormationRoleArn // "") | . == "" or test("^arn:aws[^:]*:iam::[0-9]{12}:role/[A-Za-z0-9+=,.@_/-]+$")' >/dev/null config '.runtime.architecture == "arm64" or .runtime.architecture == "x86_64"' >/dev/null config '.runtime.instanceType | test("^[a-z0-9.]+$")' >/dev/null config '.runtime.volumeSizeGiB | type == "number" and . >= 20 and . <= 1024' >/dev/null config '.models.claude | test("^[A-Za-z0-9._:/-]+$")' >/dev/null config '.models.codex | test("^[A-Za-z0-9._:/-]+$")' >/dev/null +config '.versions.awsCli | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")' >/dev/null config '.versions.claudeCode | test("^[0-9]+\\.[0-9]+\\.[0-9]+$")' >/dev/null config '.versions.codex | test("^[0-9]+\\.[0-9]+\\.[0-9]+([-.][A-Za-z0-9.]+)?$")' >/dev/null -config '.users | type == "array" and length > 0' >/dev/null -config '[.users[].email | ascii_downcase] | all(test("^[^[:space:]@]+@[^[:space:]@]+\\.[^[:space:]@]+$"))' >/dev/null -config '[.users[].email | ascii_downcase] | length == (unique | length)' >/dev/null + +IDENTITY_CENTER_METADATA_URL="$(config '.identityCenter.metadataUrl // ""')" +IDENTITY_CENTER_METADATA_FILE="$(config '.identityCenter.metadataFile // ""')" +if [[ -n "$IDENTITY_CENTER_METADATA_URL" && -n "$IDENTITY_CENTER_METADATA_FILE" ]]; then + fail "Set only one of identityCenter.metadataUrl or identityCenter.metadataFile" +fi +if [[ -n "$IDENTITY_CENTER_METADATA_FILE" ]]; then + if [[ "$IDENTITY_CENTER_METADATA_FILE" != /* ]]; then + IDENTITY_CENTER_METADATA_FILE="$ROOT_DIR/$IDENTITY_CENTER_METADATA_FILE" + fi + [[ -f "$IDENTITY_CENTER_METADATA_FILE" ]] || fail "identityCenter.metadataFile does not point to a readable file" + [[ "$(wc -c <"$IDENTITY_CENTER_METADATA_FILE")" -le 131072 ]] || \ + fail "identityCenter.metadataFile exceeds Cognito's 131072-byte limit" + grep -E '<([[:alnum:]_.-]+:)?EntityDescriptor([[:space:]>])' "$IDENTITY_CENTER_METADATA_FILE" >/dev/null || \ + fail "identityCenter.metadataFile is not a SAML metadata document" +fi say "Checking AWS credentials and region" aws_cli sts get-caller-identity --query Arn --output text | sed -E 's#arn:aws[^:]*:iam::[0-9]+:#arn:aws:iam:::#; s#arn:aws[^:]*:sts::[0-9]+:#arn:aws:sts:::#' @@ -32,13 +49,17 @@ aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templ aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templates/foundation.yaml" >/dev/null aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templates/image.yaml" >/dev/null aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templates/runtime.yaml" >/dev/null +aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templates/provisioning.yaml" >/dev/null aws_cli cloudformation validate-template --template-body "file://$ROOT_DIR/templates/web.yaml" >/dev/null say "Checking configured Bedrock model names" CLAUDE_MODEL="$(config '.models.claude')" CODEX_MODEL="$(config '.models.codex')" +CLAUDE_PROFILE="$(aws_cli bedrock get-inference-profile --inference-profile-identifier "$CLAUDE_MODEL" --output json)" || \ + fail "The configured Claude model must be an available Bedrock inference profile" +jq -e '.status == "ACTIVE" and (.inferenceProfileArn | length > 0) and (.models | length > 0)' <<<"$CLAUDE_PROFILE" >/dev/null || \ + fail "The configured Claude inference profile is not active or has no destination models" aws_cli bedrock list-foundation-models --query "modelSummaries[?modelId=='$CODEX_MODEL'].modelId | [0]" --output text | grep -F "$CODEX_MODEL" >/dev/null || \ say "Codex model is not returned by list-foundation-models; deploy will verify it with the live CLI test" -[[ -n "$CLAUDE_MODEL" ]] || fail "Claude model must not be empty" say "Doctor checks passed" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index ca445ab..f0661a9 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -32,6 +32,7 @@ aws_cli() { network_stack() { printf '%s-network\n' "$(deployment_name)"; } foundation_stack() { printf '%s-foundation\n' "$(deployment_name)"; } image_stack() { printf '%s-image\n' "$(deployment_name)"; } +provisioning_stack() { printf '%s-provisioning\n' "$(deployment_name)"; } web_stack() { printf '%s-web\n' "$(deployment_name)"; } ami_parameter_path() { printf '/agentformation/%s/runtime-ami\n' "$(deployment_name)"; } @@ -56,6 +57,49 @@ normalize_email() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | xargs } +find_cognito_user_by_email() { + local user_pool_id="$1" + local email="$2" + local users + users="$(aws_cli cognito-idp list-users \ + --user-pool-id "$user_pool_id" \ + --output json)" + users="$(jq -c --arg email "$email" ' + [.Users[] | select( + ([.Attributes[] | select(.Name == "email") | .Value][0] // "" | ascii_downcase) == $email + )] + ' <<<"$users")" + [[ "$(jq 'length' <<<"$users")" == "1" ]] || \ + fail "Expected exactly one federated user for that email address" + jq -er '.[0] | [.Username, (.Attributes[] | select(.Name == "sub").Value)] | @tsv' <<<"$users" +} + +stop_provisioning_executions() { + local user_sub="$1" + local state_machine_arn executions execution_arn input_subject + if ! aws_cli cloudformation describe-stacks --stack-name "$(provisioning_stack)" >/dev/null 2>&1; then + return + fi + state_machine_arn="$(stack_output "$(provisioning_stack)" StateMachineArn)" + executions="$(aws_cli stepfunctions list-executions \ + --state-machine-arn "$state_machine_arn" \ + --status-filter RUNNING \ + --output json)" + while IFS= read -r execution_arn; do + [[ -n "$execution_arn" ]] || continue + input_subject="$(aws_cli stepfunctions describe-execution \ + --execution-arn "$execution_arn" \ + --query input \ + --output text | jq -r '.subject // ""')" + if [[ "$input_subject" == "$user_sub" ]]; then + aws_cli stepfunctions stop-execution \ + --execution-arn "$execution_arn" \ + --error AdministratorDisabledUser \ + --cause 'The federated user was disabled by an administrator.' >/dev/null + fi + done < <(jq -r '.executions[].executionArn' <<<"$executions") +} + read_option() { local option="$1" shift diff --git a/scripts/lib/stacks.sh b/scripts/lib/stacks.sh index e8ad17e..beb49ca 100755 --- a/scripts/lib/stacks.sh +++ b/scripts/lib/stacks.sh @@ -15,7 +15,7 @@ deploy_stack() { arguments=(cloudformation deploy \ --stack-name "$stack" \ --template-file "$ROOT_DIR/$template" \ - --capabilities CAPABILITY_IAM \ + --capabilities CAPABILITY_NAMED_IAM \ --no-fail-on-empty-changeset \ --tags AgentFormationDeployment="$(deployment_name)" \ --parameter-overrides "$@") diff --git a/scripts/status.sh b/scripts/status.sh index 3651abc..ec274c6 100755 --- a/scripts/status.sh +++ b/scripts/status.sh @@ -7,7 +7,7 @@ source "$SCRIPT_DIR/lib/common.sh" require_config say "Shared stacks" -for stack in "$(network_stack)" "$(foundation_stack)" "$(image_stack)" "$(web_stack)"; do +for stack in "$(network_stack)" "$(foundation_stack)" "$(image_stack)" "$(provisioning_stack)" "$(web_stack)"; do STATUS="$(aws_cli cloudformation describe-stacks --stack-name "$stack" --query 'Stacks[0].StackStatus' --output text 2>/dev/null || printf 'NOT_DEPLOYED')" printf '%-32s %s\n' "$stack" "$STATUS" done @@ -17,12 +17,16 @@ if aws_cli cloudformation describe-stacks --stack-name "$(foundation_stack)" >/d say "Assigned runtimes" aws_cli dynamodb scan \ --table-name "$TABLE_NAME" \ - --projection-expression 'email,instanceId,#status,updatedAt' \ + --projection-expression 'email,instanceId,runtimeStackName,#status,updatedAt' \ --expression-attribute-names '{"#status":"status"}' \ - --query 'Items[].{email:email.S,instance:instanceId.S,status:status.S,updated:updatedAt.S}' \ + --query 'Items[].{email:email.S,instance:instanceId.S,stack:runtimeStackName.S,status:status.S,updated:updatedAt.S}' \ --output table fi if aws_cli cloudformation describe-stacks --stack-name "$(web_stack)" >/dev/null 2>&1; then - say "Web address: $(stack_output "$(web_stack)" ServiceUrl)" + PUBLIC_URL="$(config '(.publicUrl // "") | rtrimstr("/")')" + if [[ -z "$PUBLIC_URL" ]]; then + PUBLIC_URL="$(stack_output "$(web_stack)" ServiceUrl)" + fi + say "Web address: $PUBLIC_URL" fi diff --git a/scripts/users-add.sh b/scripts/users-add.sh deleted file mode 100755 index 14bf9d8..0000000 --- a/scripts/users-add.sh +++ /dev/null @@ -1,87 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# shellcheck source=lib/common.sh -source "$SCRIPT_DIR/lib/common.sh" -# shellcheck source=lib/stacks.sh -source "$SCRIPT_DIR/lib/stacks.sh" - -require_config -EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" -[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" - -SUPPRESS_INVITE=false -for argument in "$@"; do - [[ "$argument" == "--suppress-invite" ]] && SUPPRESS_INVITE=true -done - -USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" -TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -UPLOAD_BUCKET="$(stack_output "$(foundation_stack)" UploadBucketName)" - -if aws_cli cognito-idp admin-get-user --user-pool-id "$USER_POOL_ID" --username "$EMAIL" >/dev/null 2>&1; then - say "The Cognito user already exists; keeping the current invitation state" -else - CREATE_ARGUMENTS=( - cognito-idp admin-create-user - --user-pool-id "$USER_POOL_ID" - --username "$EMAIL" - --user-attributes "Name=email,Value=$EMAIL" "Name=email_verified,Value=true" - ) - if [[ "$SUPPRESS_INVITE" == "true" ]]; then - CREATE_ARGUMENTS+=(--message-action SUPPRESS) - say "Creating the test user without sending an invitation" - else - say "Creating the user and sending a Cognito invitation" - fi - aws_cli "${CREATE_ARGUMENTS[@]}" >/dev/null -fi - -aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$EMAIL" -USER_SUB="$(aws_cli cognito-idp admin-get-user \ - --user-pool-id "$USER_POOL_ID" \ - --username "$EMAIL" \ - --query "UserAttributes[?Name=='sub'].Value | [0]" \ - --output text)" -[[ "$USER_SUB" != "None" && -n "$USER_SUB" ]] || fail "Cognito did not return a user subject" - -RUNTIME_SUFFIX="$(hash_text "$USER_SUB" | cut -c1-12)" -RUNTIME_STACK="$(deployment_name)-runtime-$RUNTIME_SUFFIX" -deploy_stack "$RUNTIME_STACK" templates/runtime.yaml \ - DeploymentName="$(deployment_name)" \ - NetworkStackName="$(network_stack)" \ - UserSubject="$USER_SUB" \ - AmiParameterPath="$(ami_parameter_path)" \ - InstanceType="$(config '.runtime.instanceType')" \ - Architecture="$(config '.runtime.architecture')" \ - VolumeSizeGiB="$(config '.runtime.volumeSizeGiB')" \ - ClaudeModelId="$(config '.models.claude')" \ - CodexModelId="$(config '.models.codex')" \ - UploadBucketName="$UPLOAD_BUCKET" - -INSTANCE_ID="$(stack_output "$RUNTIME_STACK" InstanceId)" -INSTANCE_STATE="$(aws_cli ec2 describe-instances \ - --instance-ids "$INSTANCE_ID" \ - --query 'Reservations[0].Instances[0].State.Name' \ - --output text)" -if [[ "$INSTANCE_STATE" == "stopping" ]]; then - say "Waiting for the existing runtime to stop before restarting it" - aws_cli ec2 wait instance-stopped --instance-ids "$INSTANCE_ID" - INSTANCE_STATE="stopped" -fi -if [[ "$INSTANCE_STATE" == "stopped" ]]; then - say "Restarting the existing runtime" - aws_cli ec2 start-instances --instance-ids "$INSTANCE_ID" >/dev/null -fi -UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -ITEM="$(jq -cn \ - --arg userSub "$USER_SUB" \ - --arg email "$EMAIL" \ - --arg instanceId "$INSTANCE_ID" \ - --arg stackName "$RUNTIME_STACK" \ - --arg updatedAt "$UPDATED_AT" \ - '{userSub:{S:$userSub},email:{S:$email},instanceId:{S:$instanceId},runtimeStackName:{S:$stackName},status:{S:"active"},updatedAt:{S:$updatedAt}}')" -aws_cli dynamodb put-item --table-name "$TABLE_NAME" --item "$ITEM" - -say "The user now has one private runtime assigned" diff --git a/scripts/users-disable.sh b/scripts/users-disable.sh index 9cddad2..bb7bd54 100755 --- a/scripts/users-disable.sh +++ b/scripts/users-disable.sh @@ -4,38 +4,65 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib/common.sh source "$SCRIPT_DIR/lib/common.sh" +# shellcheck source=lib/stacks.sh +source "$SCRIPT_DIR/lib/stacks.sh" require_config EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" [[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -USER_SUB="$(aws_cli cognito-idp admin-get-user \ - --user-pool-id "$USER_POOL_ID" \ - --username "$EMAIL" \ - --query "UserAttributes[?Name=='sub'].Value | [0]" \ - --output text)" -[[ "$USER_SUB" != "None" && -n "$USER_SUB" ]] || fail "Cognito user was not found" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" -INSTANCE_ID="$(aws_cli dynamodb get-item \ +RUNTIME="$(aws_cli dynamodb get-item \ --table-name "$TABLE_NAME" \ --key "$KEY" \ --consistent-read \ - --query 'Item.instanceId.S' \ - --output text)" + --output json)" +STATUS="$(jq -r '.Item.status.S // ""' <<<"$RUNTIME")" +INSTANCE_ID="$(jq -r '.Item.instanceId.S // ""' <<<"$RUNTIME")" +RUNTIME_STACK="$(jq -r '.Item.runtimeStackName.S // ""' <<<"$RUNTIME")" -say "Disabling sign-in before stopping the assigned runtime" -aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$EMAIL" +say "Disabling company sign-in before changing the assigned runtime" +aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" -aws_cli dynamodb update-item \ - --table-name "$TABLE_NAME" \ - --key "$KEY" \ - --update-expression 'SET #status = :status, updatedAt = :updatedAt' \ - --expression-attribute-names '{"#status":"status"}' \ - --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":status":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" >/dev/null + +if [[ -z "$STATUS" ]]; then + say "The user is disabled; no runtime has been created" + exit +fi + +if [[ "$STATUS" == "provisioning" ]]; then + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #status = :failed, updatedAt = :updatedAt' \ + --condition-expression '#status = :provisioning' \ + --expression-attribute-names '{"#status":"status"}' \ + --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":failed":{S:"failed"},":provisioning":{S:"provisioning"},":updatedAt":{S:$updatedAt}}')" >/dev/null + stop_provisioning_executions "$USER_SUB" + if [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]]; then + delete_stack "$RUNTIME_STACK" + fi + say "The user is disabled and the unfinished runtime was removed" + exit +fi + +if [[ "$STATUS" == "active" || "$STATUS" == "disabled" ]]; then + aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #status = :disabled, updatedAt = :updatedAt' \ + --expression-attribute-names '{"#status":"status"}' \ + --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" >/dev/null +fi if [[ "$INSTANCE_ID" =~ ^i-[0-9a-f]+$ ]]; then aws_cli ec2 stop-instances --instance-ids "$INSTANCE_ID" >/dev/null fi -say "The user is disabled and the runtime is stopping; its disk is preserved" +if [[ "$STATUS" == "active" || "$STATUS" == "disabled" ]]; then + say "The user is disabled and the runtime is stopping; its disk is preserved" +else + say "The user is disabled; there is no active runtime to stop" +fi diff --git a/scripts/users-enable.sh b/scripts/users-enable.sh new file mode 100755 index 0000000..ea5a5ce --- /dev/null +++ b/scripts/users-enable.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "$SCRIPT_DIR/lib/common.sh" + +require_config +EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" +[[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" +USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" +TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") + +KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" +RUNTIME="$(aws_cli dynamodb get-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --consistent-read \ + --output json)" +INSTANCE_ID="$(jq -r '.Item.instanceId.S // ""' <<<"$RUNTIME")" +STATUS="$(jq -r '.Item.status.S // ""' <<<"$RUNTIME")" + +if [[ -z "$STATUS" || "$STATUS" == "failed" ]]; then + aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" + say "The user is enabled and can create or retry an environment" + exit +fi + +if [[ "$STATUS" == "active" ]]; then + aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" + say "The user is enabled and the runtime is already active" + exit +fi + +[[ "$STATUS" == "disabled" ]] || fail "That runtime is not ready to be enabled" +[[ "$INSTANCE_ID" =~ ^i-[0-9a-f]+$ ]] || fail "The disabled runtime does not have a valid instance ID" + +say "Starting the preserved runtime before restoring sign-in" +aws_cli ec2 start-instances --instance-ids "$INSTANCE_ID" >/dev/null +aws_cli cognito-idp admin-enable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" +UPDATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" +if ! aws_cli dynamodb update-item \ + --table-name "$TABLE_NAME" \ + --key "$KEY" \ + --update-expression 'SET #status = :active, updatedAt = :updatedAt' \ + --condition-expression '#status = :disabled' \ + --expression-attribute-names '{"#status":"status"}' \ + --expression-attribute-values "$(jq -cn --arg updatedAt "$UPDATED_AT" '{":active":{S:"active"},":disabled":{S:"disabled"},":updatedAt":{S:$updatedAt}}')" >/dev/null; then + aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" || true + fail "The runtime changed while it was being enabled; sign-in remains disabled" +fi +say "The user is enabled and the preserved runtime is starting" diff --git a/scripts/users-purge.sh b/scripts/users-purge.sh index 249e3cb..598a42d 100755 --- a/scripts/users-purge.sh +++ b/scripts/users-purge.sh @@ -13,12 +13,7 @@ EMAIL="$(prompt_email "$(read_option --email "$@" || true)")" [[ "$EMAIL" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]] || fail "Enter a valid email address" USER_POOL_ID="$(stack_output "$(foundation_stack)" UserPoolId)" TABLE_NAME="$(stack_output "$(foundation_stack)" UserRegistryTableName)" -USER_SUB="$(aws_cli cognito-idp admin-get-user \ - --user-pool-id "$USER_POOL_ID" \ - --username "$EMAIL" \ - --query "UserAttributes[?Name=='sub'].Value | [0]" \ - --output text)" -[[ "$USER_SUB" != "None" && -n "$USER_SUB" ]] || fail "Cognito user was not found" +IFS=$'\t' read -r COGNITO_USERNAME USER_SUB < <(find_cognito_user_by_email "$USER_POOL_ID" "$EMAIL") KEY="$(jq -cn --arg userSub "$USER_SUB" '{userSub:{S:$userSub}}')" RUNTIME_STACK="$(aws_cli dynamodb get-item \ @@ -28,10 +23,11 @@ RUNTIME_STACK="$(aws_cli dynamodb get-item \ --query 'Item.runtimeStackName.S' \ --output text)" -aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$EMAIL" +aws_cli cognito-idp admin-disable-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" +stop_provisioning_executions "$USER_SUB" if [[ "$RUNTIME_STACK" == "$(deployment_name)-runtime-"* ]]; then delete_stack "$RUNTIME_STACK" fi aws_cli dynamodb delete-item --table-name "$TABLE_NAME" --key "$KEY" -aws_cli cognito-idp admin-delete-user --user-pool-id "$USER_POOL_ID" --username "$EMAIL" +aws_cli cognito-idp admin-delete-user --user-pool-id "$USER_POOL_ID" --username "$COGNITO_USERNAME" say "The user, runtime, and persistent runtime disk were deleted" diff --git a/templates/foundation.yaml b/templates/foundation.yaml index d4e1c44..c7a968b 100644 --- a/templates/foundation.yaml +++ b/templates/foundation.yaml @@ -17,6 +17,13 @@ Parameters: Type: String Default: http://localhost:3000/ AllowedPattern: '^https?://[^ ]+$' + ConfigureCaseInsensitiveUsernames: + Type: String + Default: 'true' + AllowedValues: ['true', 'false'] + +Conditions: + UseCaseInsensitiveUsernames: !Equals [!Ref ConfigureCaseInsensitiveUsernames, 'true'] Resources: UserPool: @@ -25,24 +32,13 @@ Resources: UserPoolName: !Sub '${AWS::StackName}-users' UsernameAttributes: [email] AutoVerifiedAttributes: [email] + MfaConfiguration: 'OFF' AdminCreateUserConfig: AllowAdminCreateUserOnly: true - AccountRecoverySetting: - RecoveryMechanisms: - - Name: verified_email - Priority: 1 - Policies: - PasswordPolicy: - MinimumLength: 14 - RequireLowercase: true - RequireNumbers: true - RequireSymbols: true - RequireUppercase: true - TemporaryPasswordValidityDays: 7 - MfaConfiguration: OPTIONAL - EnabledMfas: [SOFTWARE_TOKEN_MFA] - UserAttributeUpdateSettings: - AttributesRequireVerificationBeforeUpdate: [email] + UsernameConfiguration: !If + - UseCaseInsensitiveUsernames + - { CaseSensitive: false } + - !Ref AWS::NoValue UserPoolTags: AgentFormationDeployment: !Ref DeploymentName @@ -58,7 +54,7 @@ Resources: ClientName: !Sub '${AWS::StackName}-web' UserPoolId: !Ref UserPool GenerateSecret: true - SupportedIdentityProviders: [COGNITO] + ExplicitAuthFlows: [ALLOW_REFRESH_TOKEN_AUTH] AllowedOAuthFlowsUserPoolClient: true AllowedOAuthFlows: [code] AllowedOAuthScopes: [openid, email, profile] @@ -76,6 +72,11 @@ Resources: UserRegistry: Type: AWS::DynamoDB::Table + Metadata: + checkov: + skip: + - id: CKV_AWS_119 + comment: AWS-managed DynamoDB encryption is enabled; a per-deployment customer key adds cost and key lifecycle risk without changing the app isolation boundary. DeletionPolicy: Delete UpdateReplacePolicy: Delete Properties: @@ -93,6 +94,13 @@ Resources: UploadBucket: Type: AWS::S3::Bucket + Metadata: + checkov: + skip: + - id: CKV_AWS_18 + comment: Account-wide audit logging belongs to the operator; a second project log bucket would retain employee object-access metadata and complicate complete cleanup. + - id: CKV_AWS_21 + comment: This is a one-day staging bucket; versioning would retain deleted upload contents and make the documented privacy deletion behavior misleading. DeletionPolicy: Delete UpdateReplacePolicy: Delete Properties: @@ -116,8 +124,31 @@ Resources: Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + UploadBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref UploadBucket + PolicyDocument: + Version: '2012-10-17' + Statement: + - Sid: DenyInsecureTransport + Effect: Deny + Principal: '*' + Action: s3:* + Resource: + - !Sub 'arn:${AWS::Partition}:s3:::${UploadBucket}' + - !Sub 'arn:${AWS::Partition}:s3:::${UploadBucket}/*' + Condition: + Bool: + aws:SecureTransport: 'false' + WebRepository: Type: AWS::ECR::Repository + Metadata: + checkov: + skip: + - id: CKV_AWS_136 + comment: ECR uses AWS-managed encryption at rest; a customer key is optional operator policy, not an application trust-boundary control. DeletionPolicy: Delete UpdateReplacePolicy: Delete Properties: @@ -131,6 +162,11 @@ Resources: AuthSecret: Type: AWS::SecretsManager::Secret + Metadata: + checkov: + skip: + - id: CKV_AWS_149 + comment: Secrets Manager uses its AWS-managed encryption key; a customer key would add recurring cost and separate key deletion handling to this reference deployment. Properties: Description: AgentFormation Auth.js session secret. GenerateSecretString: @@ -141,6 +177,11 @@ Resources: CognitoClientSecret: Type: AWS::SecretsManager::Secret + Metadata: + checkov: + skip: + - id: CKV_AWS_149 + comment: Secrets Manager uses its AWS-managed encryption key; a customer key would add recurring cost and separate key deletion handling to this reference deployment. Properties: Description: Populated by the guided deploy command from the Cognito client. SecretString: pending-deploy @@ -175,7 +216,7 @@ Resources: export SHELL=/bin/bash export PATH=/home/agentformation/.local/bin:/usr/local/bin:/usr/bin:/bin cd /workspace - exec tmux new-session -A -s '{{ tmuxSession }}' -c /workspace + exec tmux set-option -g mouse on \; set-option -g history-limit 100000 \; new-session -A -s '{{ tmuxSession }}' -c /workspace Tags: - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } @@ -188,6 +229,10 @@ Outputs: Value: !Sub 'https://cognito-idp.${AWS::Region}.${AWS::URLSuffix}/${UserPool}' CognitoDomain: Value: !Sub 'https://${CognitoDomainPrefix}.auth.${AWS::Region}.amazoncognito.com' + CognitoSamlAcsUrl: + Value: !Sub 'https://${CognitoDomainPrefix}.auth.${AWS::Region}.amazoncognito.com/saml2/idpresponse' + CognitoSamlAudience: + Value: !Sub 'urn:amazon:cognito:sp:${UserPool}' UserRegistryTableName: Value: !Ref UserRegistry UploadBucketName: diff --git a/templates/image.yaml b/templates/image.yaml index 3b65c5d..f5a3b95 100644 --- a/templates/image.yaml +++ b/templates/image.yaml @@ -16,6 +16,14 @@ Parameters: Type: String Default: c7g.large AllowedValues: [c7g.large, t4g.medium, c7i.large, t3.medium] + AwsCliVersion: + Type: String + Default: 2.36.29 + AllowedPattern: '^\d+\.\d+\.\d+$' + AwsCliInstallerSha256: + Type: String + Default: 80f4b79b35bb5c427261e8e9f0fd6959ad3513a697f6e37d823959a72cb654dc + AllowedPattern: '^[0-9a-f]{64}$' ClaudeCodeVersion: Type: String Default: 2.1.235 @@ -24,6 +32,10 @@ Parameters: Type: String Default: 0.148.0 AllowedPattern: '^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$' + CodexInstallerSha256: + Type: String + Default: ba92dd27e5c06f0d3bbc58bfa4b9cfb6599cd2742fbb1f92a2765e6c07dedb5a + AllowedPattern: '^[0-9a-f]{64}$' AmiParameterPath: Type: String AllowedPattern: '^/[A-Za-z0-9/_.-]+$' @@ -107,10 +119,11 @@ Resources: inputs: commands: - set -euo pipefail - - curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-$(uname -m).zip" -o /tmp/awscliv2.zip - - unzip -q /tmp/awscliv2.zip -d /tmp - - /tmp/aws/install --bin-dir /usr/local/bin --install-dir /usr/local/aws-cli --update - - rm -rf /tmp/aws /tmp/awscliv2.zip + - curl -fsSL https://awscli.amazonaws.com/v2/install.sh -o /tmp/install-aws-cli.sh + - echo "${AwsCliInstallerSha256} /tmp/install-aws-cli.sh" | sha256sum -c - + - chmod 0755 /tmp/install-aws-cli.sh + - /tmp/install-aws-cli.sh --version ${AwsCliVersion} --system --quiet + - rm -f /tmp/install-aws-cli.sh - name: CreateRuntimeUser action: ExecuteBash inputs: @@ -142,6 +155,7 @@ Resources: commands: - set -euo pipefail - curl -fsSL https://chatgpt.com/codex/install.sh -o /tmp/install-codex.sh + - echo "${CodexInstallerSha256} /tmp/install-codex.sh" | sha256sum -c - - chmod 0755 /tmp/install-codex.sh - sudo -u agentformation env HOME=/home/agentformation CODEX_HOME=/home/agentformation/.codex CODEX_INSTALL_DIR=/home/agentformation/.local/bin CODEX_RELEASE=${CodexVersion} CODEX_NON_INTERACTIVE=1 /tmp/install-codex.sh - rm -f /tmp/install-codex.sh @@ -164,7 +178,7 @@ Resources: - dpkg-query -W claude-code | grep -F "${ClaudeCodeVersion}-1" - apt-mark showhold | grep -Fx claude-code - sudo -u agentformation env HOME=/home/agentformation /home/agentformation/.local/bin/codex --version | grep -F "${CodexVersion}" - - /usr/local/bin/aws --version + - /usr/local/bin/aws --version | grep -F "aws-cli/${AwsCliVersion} " - bwrap --version - git --version - tmux -V @@ -179,7 +193,7 @@ Resources: - dpkg-query -W claude-code | grep -F "${ClaudeCodeVersion}-1" - apt-mark showhold | grep -Fx claude-code - sudo -u agentformation env HOME=/home/agentformation /home/agentformation/.local/bin/codex --version | grep -F "${CodexVersion}" - - /usr/local/bin/aws --version + - /usr/local/bin/aws --version | grep -F "aws-cli/${AwsCliVersion} " - bwrap --version - systemctl is-enabled docker - systemctl is-active snap.amazon-ssm-agent.amazon-ssm-agent.service diff --git a/templates/provisioning.yaml b/templates/provisioning.yaml new file mode 100644 index 0000000..7a55f06 --- /dev/null +++ b/templates/provisioning.yaml @@ -0,0 +1,445 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Restricted self-service provisioning for one AgentFormation runtime per federated user. + +Parameters: + DeploymentName: + Type: String + AllowedPattern: "^[a-z][a-z0-9-]{2,31}$" + UserPoolId: + Type: String + MinLength: 1 + UserRegistryTableName: + Type: String + MinLength: 1 + RuntimeTemplateUrl: + Type: String + AllowedPattern: "^https://[^ ]+$" + RuntimeTemplateBucket: + Type: String + MinLength: 3 + RuntimeTemplateKey: + Type: String + AllowedPattern: '^provisioning/runtime-[0-9a-f]{64}\.yaml$' + RuntimeSubnetId: + Type: AWS::EC2::Subnet::Id + RuntimeSecurityGroupId: + Type: AWS::EC2::SecurityGroup::Id + RuntimeAmiId: + Type: AWS::EC2::Image::Id + InstanceType: + Type: String + AllowedValues: + [ + t4g.medium, + t4g.large, + c7g.large, + c7g.xlarge, + m7g.large, + m7g.xlarge, + t3.medium, + t3.large, + c7i.large, + c7i.xlarge, + m7i.large, + m7i.xlarge, + ] + Architecture: + Type: String + AllowedValues: [arm64, x86_64] + VolumeSizeGiB: + Type: String + AllowedPattern: "^(2[0-9]|[3-9][0-9]|[1-9][0-9]{2}|10[0-1][0-9]|102[0-4])$" + ClaudeModelId: + Type: String + MinLength: 1 + MaxLength: 256 + AllowedPattern: "^[A-Za-z0-9._:/-]+$" + ClaudeInferenceProfileArn: + Type: String + MinLength: 20 + ClaudeFoundationModelArns: + Type: CommaDelimitedList + CodexModelId: + Type: String + MinLength: 1 + MaxLength: 256 + AllowedPattern: "^[A-Za-z0-9._:/-]+$" + UploadBucketName: + Type: String + MinLength: 3 + +Resources: + RuntimeCloudFormationRole: + Type: AWS::IAM::Role + Properties: + RoleName: !Sub "${DeploymentName}-runtime-cfn" + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: { Service: cloudformation.amazonaws.com } + Action: sts:AssumeRole + Policies: + - PolicyName: ManageRuntimeIam + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: ManageNamedRuntimeRoles + Effect: Allow + Action: + - iam:CreateRole + - iam:DeleteRole + - iam:GetRole + - iam:GetRolePolicy + - iam:PutRolePolicy + - iam:DeleteRolePolicy + - iam:ListRolePolicies + - iam:ListAttachedRolePolicies + - iam:ListInstanceProfilesForRole + - iam:TagRole + - iam:UntagRole + - iam:AttachRolePolicy + - iam:DetachRolePolicy + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-*" + - Sid: ManageNamedRuntimeInstanceProfiles + Effect: Allow + Action: + - iam:CreateInstanceProfile + - iam:DeleteInstanceProfile + - iam:GetInstanceProfile + - iam:AddRoleToInstanceProfile + - iam:RemoveRoleFromInstanceProfile + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:instance-profile/${DeploymentName}-runtime-*" + - Sid: PassOnlyRuntimeRolesToEc2 + Effect: Allow + Action: iam:PassRole + Resource: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/${DeploymentName}-runtime-*" + Condition: + StringEquals: + iam:PassedToService: ec2.amazonaws.com + - PolicyName: ManageRuntimeInstances + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: UseReviewedRuntimeDependencies + Effect: Allow + Action: ec2:RunInstances + Resource: + - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}::image/${RuntimeAmiId}" + - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:subnet/${RuntimeSubnetId}" + - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:security-group/${RuntimeSecurityGroupId}" + - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:network-interface/*" + - !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:volume/*" + - Sid: LaunchTaggedRuntimeInstance + Effect: Allow + Action: ec2:RunInstances + Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*" + Condition: + StringEquals: + "aws:RequestTag/AgentFormationDeployment": !Ref DeploymentName + - Sid: TagRuntimeDuringLaunch + Effect: Allow + Action: ec2:CreateTags + Resource: "*" + Condition: + StringEquals: + ec2:CreateAction: RunInstances + - Sid: RemoveDeploymentRuntime + Effect: Allow + Action: ec2:TerminateInstances + Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*" + Condition: + StringEquals: + "ec2:ResourceTag/AgentFormationDeployment": !Ref DeploymentName + - Sid: ReadRuntimeState + Effect: Allow + Action: + - ec2:DescribeImages + - ec2:DescribeInstances + - ec2:DescribeInstanceAttribute + - ec2:DescribeSecurityGroups + - ec2:DescribeSubnets + - ec2:DescribeTags + - ec2:DescribeVolumes + Resource: "*" + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + + ProvisioningStateMachineRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: { Service: states.amazonaws.com } + Action: sts:AssumeRole + Policies: + - PolicyName: ReadReviewedRuntimeTemplate + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: s3:GetObject + Resource: !Sub "arn:${AWS::Partition}:s3:::${RuntimeTemplateBucket}/${RuntimeTemplateKey}" + - PolicyName: ValidateFederatedUser + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: cognito-idp:ListUsers + Resource: !Sub "arn:${AWS::Partition}:cognito-idp:${AWS::Region}:${AWS::AccountId}:userpool/${UserPoolId}" + - PolicyName: UpdateProvisioningRegistry + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: [dynamodb:PutItem, dynamodb:UpdateItem] + Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UserRegistryTableName}" + - PolicyName: CreateRestrictedRuntimeStack + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: cloudformation:CreateStack + Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${DeploymentName}-runtime-*/*" + Condition: + StringEquals: + cloudformation:RoleArn: !GetAtt RuntimeCloudFormationRole.Arn + cloudformation:TemplateUrl: !Ref RuntimeTemplateUrl + - Effect: Allow + Action: cloudformation:DescribeStacks + Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${DeploymentName}-runtime-*/*" + - Effect: Allow + Action: iam:PassRole + Resource: !GetAtt RuntimeCloudFormationRole.Arn + Condition: + StringEquals: + iam:PassedToService: cloudformation.amazonaws.com + - Effect: Allow + Action: ec2:DescribeInstances + Resource: "*" + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + + RuntimeProvisioningStateMachine: + Type: AWS::StepFunctions::StateMachine + Properties: + StateMachineName: !Sub "${DeploymentName}-runtime-provisioning" + StateMachineType: STANDARD + RoleArn: !GetAtt ProvisioningStateMachineRole.Arn + Definition: + Comment: Validate one federated user and create only that user's reviewed AgentFormation runtime stack. + StartAt: PrepareSubjectParts + States: + PrepareSubjectParts: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "subjectParts.$": States.StringSplit($.subject, '-') + Next: PrepareRuntimeInput + PrepareRuntimeInput: + Type: Pass + Parameters: + "subject.$": $.subject + "email.$": $.email + "stackName.$": !Sub "States.Format('${DeploymentName}-runtime-{}{}{}', States.ArrayGetItem($.subjectParts, 0), States.ArrayGetItem($.subjectParts, 1), States.ArrayGetItem($.subjectParts, 2))" + Next: FindFederatedUser + FindFederatedUser: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cognitoidentityprovider:listUsers" + Parameters: + UserPoolId: !Ref UserPoolId + "Filter.$": 'States.Format(''sub = "{}"'', $.subject)' + Limit: 2 + ResultPath: $.identity + Next: RequireOneFederatedUser + RequireOneFederatedUser: + Type: Choice + Choices: + - And: + - Variable: $.identity.Users[0].Username + IsPresent: true + - Variable: $.identity.Users[0].Enabled + BooleanEquals: true + - Not: + Variable: $.identity.Users[1].Username + IsPresent: true + Next: RegisterProvisioning + Default: InvalidIdentity + RegisterProvisioning: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:putItem" + Parameters: + TableName: !Ref UserRegistryTableName + Item: + userSub: { "S.$": $.subject } + email: { "S.$": $.email } + runtimeStackName: { "S.$": $.stackName } + status: { S: provisioning } + provisioningStartedAt: { "S.$": $$.State.EnteredTime } + updatedAt: { "S.$": $$.State.EnteredTime } + ConditionExpression: "attribute_not_exists(userSub) OR #status = :failed" + ExpressionAttributeNames: + "#status": status + ExpressionAttributeValues: + ":failed": { S: failed } + ResultPath: $.registryWrite + Catch: + - ErrorEquals: + - DynamoDb.ConditionalCheckFailedException + - DynamoDB.ConditionalCheckFailedException + ResultPath: $.duplicate + Next: AlreadyProvisioned + Next: CreateRuntimeStack + CreateRuntimeStack: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:createStack" + Parameters: + "StackName.$": $.stackName + TemplateURL: !Ref RuntimeTemplateUrl + RoleARN: !GetAtt RuntimeCloudFormationRole.Arn + Capabilities: [CAPABILITY_NAMED_IAM] + OnFailure: DELETE + TimeoutInMinutes: 30 + Parameters: + - ParameterKey: DeploymentName + ParameterValue: !Ref DeploymentName + - ParameterKey: RuntimeSubnetId + ParameterValue: !Ref RuntimeSubnetId + - ParameterKey: RuntimeSecurityGroupId + ParameterValue: !Ref RuntimeSecurityGroupId + - ParameterKey: UserSubject + "ParameterValue.$": $.subject + - ParameterKey: AmiId + ParameterValue: !Ref RuntimeAmiId + - ParameterKey: InstanceType + ParameterValue: !Ref InstanceType + - ParameterKey: Architecture + ParameterValue: !Ref Architecture + - ParameterKey: VolumeSizeGiB + ParameterValue: !Ref VolumeSizeGiB + - ParameterKey: ClaudeModelId + ParameterValue: !Ref ClaudeModelId + - ParameterKey: ClaudeInferenceProfileArn + ParameterValue: !Ref ClaudeInferenceProfileArn + - ParameterKey: ClaudeFoundationModelArns + ParameterValue: !Join [",", !Ref ClaudeFoundationModelArns] + - ParameterKey: CodexModelId + ParameterValue: !Ref CodexModelId + - ParameterKey: UploadBucketName + ParameterValue: !Ref UploadBucketName + Tags: + - Key: AgentFormationDeployment + Value: !Ref DeploymentName + - Key: AgentFormationUserSubject + "Value.$": $.subject + ResultPath: $.createStack + Catch: + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: MarkProvisioningFailed + Next: WaitForRuntimeStack + WaitForRuntimeStack: + Type: Wait + Seconds: 15 + Next: ReadRuntimeStack + ReadRuntimeStack: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:cloudformation:describeStacks" + Parameters: + "StackName.$": $.stackName + ResultPath: $.stack + Catch: + - ErrorEquals: [States.ALL] + ResultPath: $.provisioningError + Next: MarkProvisioningFailed + Next: CheckRuntimeStack + CheckRuntimeStack: + Type: Choice + Choices: + - Variable: $.stack.Stacks[0].StackStatus + StringEquals: CREATE_COMPLETE + Next: FindRuntimeInstance + - Variable: $.stack.Stacks[0].StackStatus + StringEquals: CREATE_IN_PROGRESS + Next: WaitForRuntimeStack + Default: MarkProvisioningFailed + FindRuntimeInstance: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:ec2:describeInstances" + Parameters: + Filters: + - Name: tag:aws:cloudformation:stack-name + "Values.$": States.Array($.stackName) + - Name: instance-state-name + Values: [pending, running, stopping, stopped] + ResultPath: $.runtime + Next: RequireRuntimeInstance + RequireRuntimeInstance: + Type: Choice + Choices: + - Variable: $.runtime.Reservations[0].Instances[0].InstanceId + IsPresent: true + Next: ActivateRuntime + Default: WaitForRuntimeRecord + WaitForRuntimeRecord: + Type: Wait + Seconds: 10 + Next: FindRuntimeInstance + ActivateRuntime: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:updateItem" + Parameters: + TableName: !Ref UserRegistryTableName + Key: + userSub: { "S.$": $.subject } + UpdateExpression: "SET #status = :active, instanceId = :instanceId, updatedAt = :updatedAt" + ConditionExpression: "#status = :provisioning AND runtimeStackName = :stackName" + ExpressionAttributeNames: + "#status": status + ExpressionAttributeValues: + ":active": { S: active } + ":provisioning": { S: provisioning } + ":instanceId": + { "S.$": "$.runtime.Reservations[0].Instances[0].InstanceId" } + ":stackName": { "S.$": $.stackName } + ":updatedAt": { "S.$": $$.State.EnteredTime } + ResultPath: $.registryWrite + Next: RuntimeReady + MarkProvisioningFailed: + Type: Task + Resource: !Sub "arn:${AWS::Partition}:states:::aws-sdk:dynamodb:updateItem" + Parameters: + TableName: !Ref UserRegistryTableName + Key: + userSub: { "S.$": $.subject } + UpdateExpression: "SET #status = :failed, updatedAt = :updatedAt" + ConditionExpression: "#status = :provisioning" + ExpressionAttributeNames: + "#status": status + ExpressionAttributeValues: + ":failed": { S: failed } + ":provisioning": { S: provisioning } + ":updatedAt": { "S.$": $$.State.EnteredTime } + ResultPath: $.registryWrite + Next: ProvisioningFailed + AlreadyProvisioned: + Type: Succeed + RuntimeReady: + Type: Succeed + InvalidIdentity: + Type: Fail + Error: InvalidFederatedIdentity + Cause: The requested subject is not a unique federated user in this deployment. + ProvisioningFailed: + Type: Fail + Error: RuntimeProvisioningFailed + Cause: The reviewed runtime stack did not reach CREATE_COMPLETE. + Tags: + - { Key: AgentFormationDeployment, Value: !Ref DeploymentName } + +Outputs: + StateMachineArn: + Value: !Ref RuntimeProvisioningStateMachine diff --git a/templates/runtime.yaml b/templates/runtime.yaml index 000d2ca..6937213 100644 --- a/templates/runtime.yaml +++ b/templates/runtime.yaml @@ -1,20 +1,21 @@ AWSTemplateFormatVersion: '2010-09-09' -Description: One private, persistent AgentFormation runtime assigned to one Cognito subject. +Description: One private, persistent AgentFormation runtime assigned to one federated subject. Parameters: DeploymentName: Type: String AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' - NetworkStackName: - Type: String - MinLength: 1 + RuntimeSubnetId: + Type: AWS::EC2::Subnet::Id + RuntimeSecurityGroupId: + Type: AWS::EC2::SecurityGroup::Id UserSubject: Type: String NoEcho: true MinLength: 8 MaxLength: 128 - AmiParameterPath: - Type: AWS::SSM::Parameter::Value + AmiId: + Type: AWS::EC2::Image::Id InstanceType: Type: String Default: m7g.xlarge @@ -33,6 +34,11 @@ Parameters: MinLength: 1 MaxLength: 256 AllowedPattern: '^[A-Za-z0-9._:/-]+$' + ClaudeInferenceProfileArn: + Type: String + MinLength: 20 + ClaudeFoundationModelArns: + Type: CommaDelimitedList CodexModelId: Type: String MinLength: 1 @@ -58,6 +64,7 @@ Resources: RuntimeRole: Type: AWS::IAM::Role Properties: + RoleName: !Sub '${AWS::StackName}-role' AssumeRolePolicyDocument: Version: '2012-10-17' Statement: @@ -71,17 +78,29 @@ Resources: PolicyDocument: Version: '2012-10-17' Statement: - - Sid: InvokeModels + - Sid: InvokeConfiguredInferenceProfile + Effect: Allow + Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream] + Resource: !Ref ClaudeInferenceProfileArn + - Sid: InvokeConfiguredFoundationModelsThroughProfile Effect: Allow Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream] - Resource: - - !Sub 'arn:${AWS::Partition}:bedrock:*::foundation-model/*' - - !Sub 'arn:${AWS::Partition}:bedrock:*:${AWS::AccountId}:inference-profile/*' - - !Sub 'arn:${AWS::Partition}:bedrock:*:${AWS::AccountId}:application-inference-profile/*' - - Sid: DiscoverModels + Resource: !Ref ClaudeFoundationModelArns + Condition: + StringEquals: + bedrock:InferenceProfileArn: !Ref ClaudeInferenceProfileArn + - Sid: ListModelMetadata Effect: Allow - Action: [bedrock:ListFoundationModels, bedrock:GetFoundationModel, bedrock:ListInferenceProfiles, bedrock:GetInferenceProfile] + Action: [bedrock:ListFoundationModels, bedrock:ListInferenceProfiles] Resource: '*' + - Sid: ReadConfiguredModelMetadata + Effect: Allow + Action: bedrock:GetFoundationModel + Resource: !Ref ClaudeFoundationModelArns + - Sid: ReadConfiguredInferenceProfile + Effect: Allow + Action: bedrock:GetInferenceProfile + Resource: !Ref ClaudeInferenceProfileArn - Sid: InvokeConfiguredOpenAIModel Effect: Allow Action: bedrock-mantle:CreateInference @@ -112,18 +131,17 @@ Resources: RuntimeInstanceProfile: Type: AWS::IAM::InstanceProfile Properties: + InstanceProfileName: !Sub '${AWS::StackName}-profile' Roles: [!Ref RuntimeRole] RuntimeInstance: Type: AWS::EC2::Instance Properties: - ImageId: !Ref AmiParameterPath + ImageId: !Ref AmiId InstanceType: !Ref InstanceType IamInstanceProfile: !Ref RuntimeInstanceProfile - SubnetId: - Fn::ImportValue: !Sub '${NetworkStackName}-PrivateSubnetId' - SecurityGroupIds: - - Fn::ImportValue: !Sub '${NetworkStackName}-RuntimeSecurityGroupId' + SubnetId: !Ref RuntimeSubnetId + SecurityGroupIds: [!Ref RuntimeSecurityGroupId] MetadataOptions: HttpEndpoint: enabled HttpTokens: required @@ -158,6 +176,9 @@ Resources: model_provider = "amazon-bedrock" model = "${CodexModelId}" + [tui] + resume_cwd = "current" + [model_providers.amazon-bedrock.aws] region = "${AWS::Region}" CODEX diff --git a/templates/web.yaml b/templates/web.yaml index f853a16..ad6c876 100644 --- a/templates/web.yaml +++ b/templates/web.yaml @@ -1,22 +1,22 @@ -AWSTemplateFormatVersion: '2010-09-09' +AWSTemplateFormatVersion: "2010-09-09" Description: AgentFormation App Runner web terminal and least-privilege service roles. Parameters: DeploymentName: Type: String - AllowedPattern: '^[a-z][a-z0-9-]{2,31}$' + AllowedPattern: "^[a-z][a-z0-9-]{2,31}$" ImageIdentifier: Type: String MinLength: 1 PublicUrl: Type: String - AllowedPattern: '^https?://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$' + AllowedPattern: "^https?://[A-Za-z0-9.-]+(?::[0-9]{1,5})?$" UserPoolClientId: Type: String MinLength: 1 CognitoIssuer: Type: String - AllowedPattern: '^https://[^ ]+$' + AllowedPattern: "^https://[^ ]+$" CognitoClientSecretArn: Type: String MinLength: 1 @@ -32,13 +32,16 @@ Parameters: TerminalSessionDocumentName: Type: String MinLength: 1 + ProvisioningStateMachineArn: + Type: String + MinLength: 20 Resources: EcrAccessRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Principal: { Service: build.apprunner.amazonaws.com } @@ -52,7 +55,7 @@ Resources: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Principal: { Service: tasks.apprunner.amazonaws.com } @@ -60,54 +63,72 @@ Resources: Policies: - PolicyName: ReadAssignedRuntime PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Action: dynamodb:GetItem - Resource: !Sub 'arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UserRegistryTableName}' + Resource: !Sub "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${UserRegistryTableName}" + - PolicyName: StartOwnRuntimeProvisioning + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: states:StartExecution + Resource: !Ref ProvisioningStateMachineArn + - PolicyName: ReadRuntimeProvisioningProgress + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: cloudformation:DescribeStackEvents + Resource: !Sub "arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${DeploymentName}-runtime-*/*" - PolicyName: ManageBrowserSessions PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Sid: StartTaggedRuntimeSession Effect: Allow Action: ssm:StartSession - Resource: !Sub 'arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*' + Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*" Condition: StringEquals: - 'ssm:resourceTag/AgentFormationDeployment': !Ref DeploymentName + "ssm:resourceTag/AgentFormationDeployment": !Ref DeploymentName - Sid: UseTerminalDocument Effect: Allow Action: ssm:StartSession - Resource: !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/${TerminalSessionDocumentName}' + Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:document/${TerminalSessionDocumentName}" - Sid: RunUploadCommand Effect: Allow Action: ssm:SendCommand - Resource: !Sub 'arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*' + Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*" Condition: StringEquals: - 'ssm:resourceTag/AgentFormationDeployment': !Ref DeploymentName + "ssm:resourceTag/AgentFormationDeployment": !Ref DeploymentName - Sid: UseUploadCommandDocument Effect: Allow Action: ssm:SendCommand - Resource: !Sub 'arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript' - - Sid: ObserveAndEndOwnedSessions + Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}::document/AWS-RunShellScript" + - Sid: ObserveUploadCommands + Effect: Allow + Action: ssm:GetCommandInvocation + Resource: "*" + - Sid: ResumeAndEndBrowserSessionsInThisAccount Effect: Allow - Action: [ssm:GetCommandInvocation, ssm:TerminateSession] - Resource: '*' + Action: [ssm:ResumeSession, ssm:TerminateSession] + Resource: !Sub "arn:${AWS::Partition}:ssm:${AWS::Region}:${AWS::AccountId}:session/*" - PolicyName: ManageUploadStaging PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Action: [s3:PutObject, s3:GetObject, s3:DeleteObject] - Resource: !Sub 'arn:${AWS::Partition}:s3:::${UploadBucketName}/uploads/*' + Resource: !Sub "arn:${AWS::Partition}:s3:::${UploadBucketName}/uploads/*" - Effect: Allow Action: s3:GetBucketLocation - Resource: !Sub 'arn:${AWS::Partition}:s3:::${UploadBucketName}' + Resource: !Sub "arn:${AWS::Partition}:s3:::${UploadBucketName}" - PolicyName: ReadApplicationSecrets PolicyDocument: - Version: '2012-10-17' + Version: "2012-10-17" Statement: - Effect: Allow Action: secretsmanager:GetSecretValue @@ -118,7 +139,7 @@ Resources: AutoScalingConfiguration: Type: AWS::AppRunner::AutoScalingConfiguration Properties: - AutoScalingConfigurationName: !Sub '${AWS::StackName}-scaling' + AutoScalingConfigurationName: !Sub "${AWS::StackName}-scaling" MaxConcurrency: 20 MaxSize: 3 MinSize: 1 @@ -128,7 +149,7 @@ Resources: WebService: Type: AWS::AppRunner::Service Properties: - ServiceName: !Sub '${DeploymentName}-web' + ServiceName: !Sub "${DeploymentName}-web" AutoScalingConfigurationArn: !Ref AutoScalingConfiguration SourceConfiguration: AuthenticationConfiguration: @@ -138,23 +159,35 @@ Resources: ImageIdentifier: !Ref ImageIdentifier ImageRepositoryType: ECR ImageConfiguration: - Port: '3000' + Port: "3000" RuntimeEnvironmentVariables: - { Name: NODE_ENV, Value: production } - { Name: AUTH_URL, Value: !Ref PublicUrl } - - { Name: AUTH_TRUST_HOST, Value: 'true' } + - { Name: AUTH_TRUST_HOST, Value: "true" } - { Name: AWS_REGION, Value: !Ref AWS::Region } + - { Name: AGENTFORMATION_DEPLOYMENT, Value: !Ref DeploymentName } - { Name: AUTH_COGNITO_ID, Value: !Ref UserPoolClientId } - { Name: AUTH_COGNITO_ISSUER, Value: !Ref CognitoIssuer } + - { Name: AUTH_COGNITO_IDENTITY_PROVIDER, Value: IdentityCenter } - { Name: USER_REGISTRY_TABLE, Value: !Ref UserRegistryTableName } + - { + Name: PROVISIONING_STATE_MACHINE_ARN, + Value: !Ref ProvisioningStateMachineArn, + } - { Name: UPLOAD_BUCKET, Value: !Ref UploadBucketName } - - { Name: SESSION_DOCUMENT_NAME, Value: !Ref TerminalSessionDocumentName } + - { + Name: SESSION_DOCUMENT_NAME, + Value: !Ref TerminalSessionDocumentName, + } RuntimeEnvironmentSecrets: - - { Name: AUTH_COGNITO_SECRET, Value: !Ref CognitoClientSecretArn } + - { + Name: AUTH_COGNITO_SECRET, + Value: !Ref CognitoClientSecretArn, + } - { Name: AUTH_SECRET, Value: !Ref AuthSecretArn } InstanceConfiguration: - Cpu: '1 vCPU' - Memory: '2 GB' + Cpu: "1 vCPU" + Memory: "2 GB" InstanceRoleArn: !GetAtt WebInstanceRole.Arn HealthCheckConfiguration: Protocol: HTTP @@ -170,4 +203,4 @@ Outputs: ServiceArn: Value: !GetAtt WebService.ServiceArn ServiceUrl: - Value: !Sub 'https://${WebService.ServiceUrl}' + Value: !Sub "https://${WebService.ServiceUrl}" diff --git a/web/.env.example b/web/.env.example index 8285c3f..36040cb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,8 +2,11 @@ AUTH_URL=http://localhost:3000 AUTH_TRUST_HOST=true AUTH_COGNITO_ID=local-client-id AUTH_COGNITO_SECRET=local-client-secret +AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example AUTH_SECRET=replace-with-at-least-32-random-characters +AGENTFORMATION_DEPLOYMENT=agentformation +PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:agentformation-runtime-provisioning AWS_REGION=us-east-1 USER_REGISTRY_TABLE=agentformation-local-users UPLOAD_BUCKET=agentformation-local-uploads diff --git a/web/Dockerfile b/web/Dockerfile index 08efc2a..d24941c 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -18,7 +18,11 @@ RUN mkdir -p public && \ AUTH_SECRET=build-only-secret-not-used-at-runtime \ AUTH_COGNITO_ID=build-only \ AUTH_COGNITO_SECRET=build-only \ + AUTH_COGNITO_IDENTITY_PROVIDER=IdentityCenter \ AUTH_COGNITO_ISSUER=https://cognito-idp.us-east-1.amazonaws.com/us-east-1_build \ + AUTH_URL=https://agentformation.example \ + AGENTFORMATION_DEPLOYMENT=agentformation \ + PROVISIONING_STATE_MACHINE_ARN=arn:aws:states:us-east-1:000000000000:stateMachine:build-only \ USER_REGISTRY_TABLE=build-only \ UPLOAD_BUCKET=build-only \ SESSION_DOCUMENT_NAME=build-only \ @@ -41,4 +45,6 @@ COPY --chown=nextjs:nodejs start.mjs ./start.mjs USER nextjs EXPOSE 3000 ENV PORT=3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(response => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1))"] CMD ["node", "start.mjs"] diff --git a/web/bun.lock b/web/bun.lock index a5ce7f7..f2b3bfc 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,8 +5,10 @@ "": { "name": "claude-code-mobile", "dependencies": { + "@aws-sdk/client-cloudformation": "3.1114.0", "@aws-sdk/client-dynamodb": "3.1114.0", "@aws-sdk/client-s3": "3.1114.0", + "@aws-sdk/client-sfn": "3.1114.0", "@aws-sdk/client-ssm": "3.1114.0", "@aws-sdk/lib-dynamodb": "3.1114.0", "@aws-sdk/s3-request-presigner": "3.1114.0", @@ -63,10 +65,14 @@ "@aws-sdk/checksums": ["@aws-sdk/checksums@3.1000.28", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw=="], + "@aws-sdk/client-cloudformation": ["@aws-sdk/client-cloudformation@3.1114.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-oN4T95AeXWanpPdlAVL7fBt0ULz7aCTwfHu//tJopKOttga/B9rIkVUNsRo/1XZY+HfSVvZfPnZQ3XgeTl7x9A=="], + "@aws-sdk/client-dynamodb": ["@aws-sdk/client-dynamodb@3.1114.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/dynamodb-codec": "^3.973.43", "@aws-sdk/middleware-endpoint-discovery": "^3.972.29", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-0oHSQWBZvjwcTuWOiyIemuGsXMwMvl2hqPiIoCvxiOOpgzIOqN/lcatygW3VGe9iDfHgcyqJfXC8B+XbKHpftA=="], "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1114.0", "", { "dependencies": { "@aws-sdk/checksums": "^3.1000.28", "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/middleware-sdk-s3": "^3.972.74", "@aws-sdk/signature-v4-multi-region": "^3.996.45", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZeAgOtB+CXFaWXph98U7a/XrBlVx1lQ2rCJRWLxLmAaQ9k5lht6DWfwnEcVjdzduK/ySao1tCjq0fBAScGqjAg=="], + "@aws-sdk/client-sfn": ["@aws-sdk/client-sfn@3.1114.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ya+0uAcvsHzWK6ipiKSrraPaTAnLnLX6ZHh0R/F7wPziTtF0NYpT5r2BwDbcdR7Dx5k1VyQrqNbBjdkhcG6xXg=="], + "@aws-sdk/client-ssm": ["@aws-sdk/client-ssm@3.1114.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.8", "@aws-sdk/credential-provider-node": "^3.972.80", "@aws-sdk/types": "^3.974.4", "@smithy/core": "^3.31.1", "@smithy/fetch-http-handler": "^5.6.13", "@smithy/node-http-handler": "^4.9.13", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-zCYpt/RAyGE6lBH2E6BNowcrw6jzO72zCYE8f5oQ9pv6Kw3VT7Jn8SCmKJ5X0sMiS0ZW8HCGLz7ksKBKX0WPnQ=="], "@aws-sdk/core": ["@aws-sdk/core@3.977.8", "", { "dependencies": { "@aws-sdk/types": "^3.974.4", "@aws-sdk/xml-builder": "^3.972.39", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.31.1", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg=="], diff --git a/web/next.config.ts b/web/next.config.ts index 4da7757..b77c960 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,16 +1,9 @@ import type { NextConfig } from "next"; -const isDev = process.env.NODE_ENV !== "production"; -const awsRegion = process.env.AWS_REGION ?? "us-east-1"; - const nextConfig: NextConfig = { output: "standalone", poweredByHeader: false, async headers() { - const scriptSrc = isDev - ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'" - : "script-src 'self' 'unsafe-inline'"; - return [ { source: "/:path*", @@ -19,23 +12,15 @@ const nextConfig: NextConfig = { { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { - key: "Strict-Transport-Security", - value: "max-age=63072000; includeSubDomains; preload", + key: "Permissions-Policy", + value: + "camera=(), geolocation=(), microphone=(), payment=(), usb=()", }, + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, { - key: "Content-Security-Policy", - value: [ - "default-src 'self'", - scriptSrc, - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data: blob:", - `connect-src 'self' wss://*.amazonaws.com https://*.s3.${awsRegion}.amazonaws.com https://*.s3.amazonaws.com`, - "font-src 'self'", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self' https://*.auth.*.amazoncognito.com https://*.amazoncognito.com", - "frame-ancestors 'none'", - ].join("; "), + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", }, ], }, diff --git a/web/package.json b/web/package.json index a8e08ec..8962250 100644 --- a/web/package.json +++ b/web/package.json @@ -14,8 +14,10 @@ "test:watch": "vitest" }, "dependencies": { + "@aws-sdk/client-cloudformation": "3.1114.0", "@aws-sdk/client-dynamodb": "3.1114.0", "@aws-sdk/client-s3": "3.1114.0", + "@aws-sdk/client-sfn": "3.1114.0", "@aws-sdk/client-ssm": "3.1114.0", "@aws-sdk/lib-dynamodb": "3.1114.0", "@aws-sdk/s3-request-presigner": "3.1114.0", diff --git a/web/src/app/api/environment/route.ts b/web/src/app/api/environment/route.ts new file mode 100644 index 0000000..77a151f --- /dev/null +++ b/web/src/app/api/environment/route.ts @@ -0,0 +1,48 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; +import { requireAuthenticatedIdentity } from "@/lib/authorization"; +import { getProvisioningProgress } from "@/lib/provisioning-status"; +import { startRuntimeProvisioning } from "@/lib/provisioning"; +import { getRuntimeForSubject } from "@/lib/registry"; +import { requireSameOriginJson } from "@/lib/request-security"; + +const requestSchema = z.object({}).strict(); + +export async function GET() { + try { + const { subject } = await requireAuthenticatedIdentity(); + const runtime = await getRuntimeForSubject(subject); + const progress = + runtime?.status === "provisioning" + ? await getProvisioningProgress(runtime) + : undefined; + return apiJsonResponse({ + status: runtime?.status ?? "not_created", + progress, + }); + } catch (error) { + return apiErrorResponse(error, "environment.read.failed"); + } +} + +export async function POST(request: NextRequest) { + try { + requireSameOriginJson(request); + requestSchema.parse(await request.json()); + const { subject, email } = await requireAuthenticatedIdentity(); + await startRuntimeProvisioning(subject, email); + return apiJsonResponse( + { + status: "provisioning", + progress: { + stage: "confirming_access", + startedAt: new Date().toISOString(), + }, + }, + { status: 202 }, + ); + } catch (error) { + return apiErrorResponse(error, "environment.provision.failed"); + } +} diff --git a/web/src/app/api/health/route.ts b/web/src/app/api/health/route.ts index b6de274..89a3a27 100644 --- a/web/src/app/api/health/route.ts +++ b/web/src/app/api/health/route.ts @@ -1,11 +1,5 @@ -import { NextResponse } from "next/server"; - -const APP_VERSION = process.env.npm_package_version ?? "0.1.0"; +import { apiJsonResponse } from "@/lib/api-error"; export function GET() { - return NextResponse.json({ - status: "ok", - version: APP_VERSION, - timestamp: new Date().toISOString(), - }); + return apiJsonResponse({ status: "ok" }); } diff --git a/web/src/app/api/oauth/loopback/route.ts b/web/src/app/api/oauth/loopback/route.ts new file mode 100644 index 0000000..1ef3e48 --- /dev/null +++ b/web/src/app/api/oauth/loopback/route.ts @@ -0,0 +1,69 @@ +import { randomUUID } from "node:crypto"; +import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; +import { requireAuthorizedRuntime } from "@/lib/authorization"; +import { getS3Client } from "@/lib/aws"; +import { getUploadBucketName } from "@/lib/env"; +import { validateOAuthCallbackUrl } from "@/lib/oauth-callback"; +import { + buildOAuthRelayCommands, + serializeOAuthCallbackForCurl, +} from "@/lib/oauth-relay"; +import { requireSameOriginJson } from "@/lib/request-security"; +import { runShellCommand } from "@/lib/ssm-command"; + +export const runtime = "nodejs"; + +const requestSchema = z.object({ callbackUrl: z.unknown() }).strict(); + +export async function POST(request: NextRequest) { + let stagedObject: { bucket: string; key: string } | undefined; + + try { + requireSameOriginJson(request); + const { subject, runtime: assignedRuntime } = + await requireAuthorizedRuntime(); + const body = requestSchema.parse(await request.json()); + const callback = validateOAuthCallbackUrl(body.callbackUrl); + const bucket = getUploadBucketName(); + const key = `uploads/${subject}/${randomUUID()}/oauth-callback.curl`; + const s3 = getS3Client(); + stagedObject = { bucket, key }; + + await s3.send( + new PutObjectCommand({ + Bucket: bucket, + Key: key, + Body: serializeOAuthCallbackForCurl(callback.callbackUrl), + ContentType: "application/octet-stream", + CacheControl: "no-store", + }), + ); + + await runShellCommand( + assignedRuntime.instanceId, + buildOAuthRelayCommands(bucket, key), + "Relay AgentFormation OAuth loopback callback", + ); + return apiJsonResponse({ ok: true }); + } catch (error) { + return apiErrorResponse(error, "oauth.loopback.failed"); + } finally { + if (stagedObject) { + try { + await getS3Client().send( + new DeleteObjectCommand({ + Bucket: stagedObject.bucket, + Key: stagedObject.key, + }), + ); + } catch (error) { + console.error("OAuth callback staging cleanup failed", { + errorName: error instanceof Error ? error.name : "UnknownError", + }); + } + } + } +} diff --git a/web/src/app/api/session/resume/route.ts b/web/src/app/api/session/resume/route.ts new file mode 100644 index 0000000..aedd53b --- /dev/null +++ b/web/src/app/api/session/resume/route.ts @@ -0,0 +1,40 @@ +import { ResumeSessionCommand } from "@aws-sdk/client-ssm"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; +import { requireAuthorizedRuntime } from "@/lib/authorization"; +import { getSsmClient } from "@/lib/aws"; +import { requireSameOriginJson } from "@/lib/request-security"; +import { verifyTerminateToken } from "@/lib/session-proof"; + +const requestSchema = z.object({ + sessionId: z.string().min(1).max(256), + terminateToken: z.string().min(1).max(256), +}); + +export async function POST(request: NextRequest) { + try { + requireSameOriginJson(request); + const { subject } = await requireAuthorizedRuntime(); + const body = requestSchema.parse(await request.json()); + if (!verifyTerminateToken(subject, body.sessionId, body.terminateToken)) { + throw new ApiError(403, "Forbidden"); + } + + const response = await getSsmClient().send( + new ResumeSessionCommand({ SessionId: body.sessionId }), + ); + if (!response.SessionId || !response.StreamUrl || !response.TokenValue) { + throw new Error("Incomplete SSM resume response"); + } + + return apiJsonResponse({ + sessionId: response.SessionId, + streamUrl: response.StreamUrl, + tokenValue: response.TokenValue, + terminateToken: body.terminateToken, + }); + } catch (error) { + return apiErrorResponse(error, "session.resume.failed"); + } +} diff --git a/web/src/app/api/session/start/route.ts b/web/src/app/api/session/start/route.ts index c6af8f8..319bd88 100644 --- a/web/src/app/api/session/start/route.ts +++ b/web/src/app/api/session/start/route.ts @@ -1,10 +1,11 @@ import { StartSessionCommand } from "@aws-sdk/client-ssm"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { z } from "zod"; -import { apiErrorResponse } from "@/lib/api-error"; +import { apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; import { requireAuthorizedRuntime } from "@/lib/authorization"; import { getSsmClient } from "@/lib/aws"; import { getSessionDocumentName } from "@/lib/env"; +import { requireSameOriginJson } from "@/lib/request-security"; import { createTerminateToken } from "@/lib/session-proof"; const requestSchema = z.object({ @@ -22,8 +23,9 @@ const requestSchema = z.object({ export async function POST(request: NextRequest) { try { + requireSameOriginJson(request); const { subject, runtime } = await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json().catch(() => ({}))); + const body = requestSchema.parse(await request.json()); const response = await getSsmClient().send( new StartSessionCommand({ Target: runtime.instanceId, @@ -37,7 +39,7 @@ export async function POST(request: NextRequest) { throw new Error("Incomplete SSM session response"); } - return NextResponse.json({ + return apiJsonResponse({ sessionId: response.SessionId, streamUrl: response.StreamUrl, tokenValue: response.TokenValue, diff --git a/web/src/app/api/session/terminate/route.ts b/web/src/app/api/session/terminate/route.ts index 437e9b2..bc54b5e 100644 --- a/web/src/app/api/session/terminate/route.ts +++ b/web/src/app/api/session/terminate/route.ts @@ -1,9 +1,10 @@ import { TerminateSessionCommand } from "@aws-sdk/client-ssm"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { z } from "zod"; -import { ApiError, apiErrorResponse } from "@/lib/api-error"; +import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; import { requireAuthorizedRuntime } from "@/lib/authorization"; import { getSsmClient } from "@/lib/aws"; +import { requireSameOriginJson } from "@/lib/request-security"; import { verifyTerminateToken } from "@/lib/session-proof"; const requestSchema = z.object({ @@ -13,6 +14,7 @@ const requestSchema = z.object({ export async function POST(request: NextRequest) { try { + requireSameOriginJson(request); const { subject } = await requireAuthorizedRuntime(); const body = requestSchema.parse(await request.json()); if (!verifyTerminateToken(subject, body.sessionId, body.terminateToken)) { @@ -22,7 +24,7 @@ export async function POST(request: NextRequest) { await getSsmClient().send( new TerminateSessionCommand({ SessionId: body.sessionId }), ); - return NextResponse.json({ ok: true }); + return apiJsonResponse({ ok: true }); } catch (error) { return apiErrorResponse(error, "session.terminate.failed"); } diff --git a/web/src/app/api/session/upload/route.ts b/web/src/app/api/session/upload/route.ts index b3515ef..bb2b157 100644 --- a/web/src/app/api/session/upload/route.ts +++ b/web/src/app/api/session/upload/route.ts @@ -5,12 +5,13 @@ import { PutObjectCommand, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest } from "next/server"; import { z } from "zod"; -import { ApiError, apiErrorResponse } from "@/lib/api-error"; +import { ApiError, apiErrorResponse, apiJsonResponse } from "@/lib/api-error"; import { requireAuthorizedRuntime } from "@/lib/authorization"; import { getS3Client } from "@/lib/aws"; import { getUploadBucketName } from "@/lib/env"; +import { requireSameOriginJson } from "@/lib/request-security"; import { runShellCommand } from "@/lib/ssm-command"; import { shellQuote } from "@/lib/shell"; @@ -20,19 +21,17 @@ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024; const SIGNED_URL_SECONDS = 5 * 60; const createSchema = z.object({ - action: z.literal("create"), filename: z.string().min(1).max(255), - contentType: z.string().min(1).max(120), - size: z.number().int().positive().max(MAX_UPLOAD_BYTES), + mimeType: z.string().min(1).max(120), + fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), }); const completeSchema = z.object({ - action: z.literal("complete"), key: z.string().min(1).max(1_024), + filename: z.string().min(1).max(255), + mimeType: z.string().min(1).max(120), + fileSize: z.number().int().positive().max(MAX_UPLOAD_BYTES), + tmuxSession: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/), }); -const requestSchema = z.discriminatedUnion("action", [ - createSchema, - completeSchema, -]); function safeFilename(value: string): string { const basename = value.split(/[\\/]/).at(-1)?.trim() ?? ""; @@ -49,48 +48,90 @@ function uploadPrefix(subject: string): string { return `uploads/${subject}/`; } +function safeMimeType(value: string): string { + if (/[\r\n]/.test(value)) { + throw new ApiError(400, "Invalid file type"); + } + return value.trim() || "application/octet-stream"; +} + export async function POST(request: NextRequest) { try { - const { subject, runtime: assignedRuntime } = - await requireAuthorizedRuntime(); - const body = requestSchema.parse(await request.json()); + requireSameOriginJson(request); + const { subject } = await requireAuthorizedRuntime(); + const body = createSchema.parse(await request.json()); const bucket = getUploadBucketName(); const s3 = getS3Client(); + const filename = safeFilename(body.filename); + const mimeType = safeMimeType(body.mimeType); + const key = `${uploadPrefix(subject)}${randomUUID()}/${filename}`; + const requiredHeaders = { "Content-Type": mimeType }; + const uploadUrl = await getSignedUrl( + s3, + new PutObjectCommand({ + Bucket: bucket, + Key: key, + ContentType: mimeType, + ContentLength: body.fileSize, + }), + { expiresIn: SIGNED_URL_SECONDS }, + ); + return apiJsonResponse({ + key, + filename, + mimeType, + fileSize: body.fileSize, + uploadUrl, + method: "PUT", + requiredHeaders, + }); + } catch (error) { + return apiErrorResponse(error, "session.upload.create.failed"); + } +} - if (body.action === "create") { - const filename = safeFilename(body.filename); - const key = `${uploadPrefix(subject)}${randomUUID()}-${filename}`; - const uploadUrl = await getSignedUrl( - s3, - new PutObjectCommand({ - Bucket: bucket, - Key: key, - ContentType: body.contentType, - ContentLength: body.size, - }), - { expiresIn: SIGNED_URL_SECONDS }, - ); - return NextResponse.json({ key, uploadUrl, filename }); +export async function PATCH(request: NextRequest) { + try { + requireSameOriginJson(request); + const { subject, runtime: assignedRuntime } = + await requireAuthorizedRuntime(); + const body = completeSchema.parse(await request.json()); + const prefix = uploadPrefix(subject); + if (!body.key.startsWith(prefix)) { + throw new ApiError(403, "Forbidden"); } - if (!body.key.startsWith(uploadPrefix(subject))) { - throw new ApiError(403, "Forbidden"); + const filename = safeFilename(body.filename); + const mimeType = safeMimeType(body.mimeType); + const suffix = body.key.slice(prefix.length); + const [uploadId, keyFilename, ...extraParts] = suffix.split("/"); + if ( + extraParts.length > 0 || + !z.string().uuid().safeParse(uploadId).success || + keyFilename !== filename + ) { + throw new ApiError(400, "Invalid upload key"); } + + const bucket = getUploadBucketName(); + const s3 = getS3Client(); const object = await s3.send( new HeadObjectCommand({ Bucket: bucket, Key: body.key }), ); - if (!object.ContentLength || object.ContentLength > MAX_UPLOAD_BYTES) { - throw new ApiError(400, "Invalid upload size"); + if ( + object.ContentLength !== body.fileSize || + object.ContentLength > MAX_UPLOAD_BYTES + ) { + throw new ApiError(400, "Uploaded file size does not match"); } - const filename = safeFilename( - body.key.slice(uploadPrefix(subject).length + 37), - ); - const destination = `/workspace/.uploads/${randomUUID()}-${filename}`; + const destinationDirectory = `/workspace/.uploads/${uploadId}`; + const destination = `${destinationDirectory}/${filename}`; await runShellCommand( assignedRuntime.instanceId, [ - "install -d -m 700 -o agentformation -g agentformation /workspace/.uploads", + `tmux has-session -t ${shellQuote(body.tmuxSession)}`, + `install -d -m 700 -o agentformation -g agentformation ${shellQuote(destinationDirectory)}`, `aws s3 cp ${shellQuote(`s3://${bucket}/${body.key}`)} ${shellQuote(destination)}`, `chown agentformation:agentformation ${shellQuote(destination)}`, `chmod 600 ${shellQuote(destination)}`, @@ -98,8 +139,13 @@ export async function POST(request: NextRequest) { "Copy AgentFormation upload to assigned runtime", ); await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: body.key })); - return NextResponse.json({ path: destination, filename }); + return apiJsonResponse({ + path: destination, + filename, + mimeType, + fileSize: body.fileSize, + }); } catch (error) { - return apiErrorResponse(error, "session.upload.failed"); + return apiErrorResponse(error, "session.upload.complete.failed"); } } diff --git a/web/src/app/auth-error/auth-error-content.tsx b/web/src/app/auth-error/auth-error-content.tsx new file mode 100644 index 0000000..d0a8411 --- /dev/null +++ b/web/src/app/auth-error/auth-error-content.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { ShieldX } from "lucide-react"; +import Link from "next/link"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; + +export function AuthErrorContent() { + const params = useSearchParams(); + const error = params.get("error"); + + return ( +
+
+ +
+
+

Access Denied

+

+ {error === "AccessDenied" + ? "Your account does not have an active AgentFormation runtime. Ask this deployment's administrator for access." + : "Authentication failed. Try again or ask this deployment's administrator for help."} +

+
+ +
+ ); +} diff --git a/web/src/app/auth-error/page.tsx b/web/src/app/auth-error/page.tsx index 11e4621..d486679 100644 --- a/web/src/app/auth-error/page.tsx +++ b/web/src/app/auth-error/page.tsx @@ -1,36 +1,9 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; -import Link from "next/link"; +import { connection } from "next/server"; import { Suspense } from "react"; -import { ShieldX } from "lucide-react"; -import { Button } from "@/components/ui/button"; - -function AuthErrorContent() { - const params = useSearchParams(); - const error = params.get("error"); - - return ( -
-
- -
-
-

Access Denied

-

- {error === "AccessDenied" - ? "Your account does not have an active AgentFormation runtime. Ask this deployment's administrator for access." - : "Authentication failed. Try again or ask this deployment's administrator for help."} -

-
- -
- ); -} +import { AuthErrorContent } from "./auth-error-content"; -export default function AuthErrorPage() { +export default async function AuthErrorPage() { + await connection(); return ( diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 33a8a41..46688c5 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -87,6 +87,21 @@ touch-action: manipulation; font-family: var(--font-mono); } + button:not(:disabled), + a[href], + [role="button"]:not([aria-disabled="true"]) { + cursor: pointer; + } +} + +@layer utilities { + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } + .no-scrollbar::-webkit-scrollbar { + display: none; + } } .xterm { @@ -104,3 +119,16 @@ .xterm .xterm-viewport::-webkit-scrollbar { display: none; } + +@media (pointer: coarse) { + .terminal-touch-pane .xterm, + .terminal-touch-pane .xterm * { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } + + .terminal-touch-pane .xterm { + pointer-events: none; + } +} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 68817f0..c93b481 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,6 +1,8 @@ import { redirect } from "next/navigation"; import { LogOut, ShieldCheck, TerminalSquare } from "lucide-react"; -import { TerminalWorkspace } from "@/components/terminal/terminal-workspace"; +import { EnvironmentSetup } from "@/components/environment-setup"; +import { MobileTerminal } from "@/components/mobile-terminal"; +import { OAuthCallbackAction } from "@/components/oauth-callback-action"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; import { cachedAuth, signIn, signOut } from "@/lib/auth"; @@ -20,7 +22,8 @@ function SignInPage() { AgentFormation

- Persistent remote coding agents, inside your AWS account. + Persistent remote coding agents, inside your company's AWS + account.

@@ -31,12 +34,12 @@ function SignInPage() { }} >

- Access is limited to users invited by this deployment's - administrator. + Access is limited to company groups assigned in AWS IAM Identity + Center. AgentFormation does not keep a separate password.

@@ -45,21 +48,27 @@ function SignInPage() { export default async function Home() { const session = await cachedAuth(); - if (!session?.user?.id) return ; + if (!session?.user?.id || !session.user.email) return ; const runtime = await getRuntimeForSubject(session.user.id); - if (!isActiveRuntime(runtime)) redirect("/auth-error?error=AccessDenied"); + if (runtime?.status === "disabled") + redirect("/auth-error?error=AccessDenied"); return (
-
-
-

AgentFormation

-

- {session.user.email} · /workspace +

+
+

+ {isActiveRuntime(runtime) + ? runtime.runtimeStackName + : "AgentFormation"} +

+

+ {session.user.email}

-
+
+ {isActiveRuntime(runtime) ? : null}
{ @@ -73,15 +82,29 @@ export default async function Home() { size="icon-sm" aria-label="Sign out" title="Sign out" + className="text-muted-foreground" > - +
-
- -
+ {isActiveRuntime(runtime) ? ( +
+ +
+ ) : ( + + )}
); } diff --git a/web/src/components/environment-setup.tsx b/web/src/components/environment-setup.tsx new file mode 100644 index 0000000..f264cfa --- /dev/null +++ b/web/src/components/environment-setup.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + CheckCircle2, + Circle, + CircleAlert, + LoaderCircle, + ServerCog, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + environmentResponseSchema, + progressForStage, + provisioningSteps, + type ProvisioningProgress, +} from "@/lib/environment-progress"; +import { cn } from "@/lib/utils"; + +type EnvironmentStatus = "not_created" | "provisioning" | "failed"; + +interface EnvironmentSetupProps { + initialStatus: EnvironmentStatus; + initialStartedAt?: string; +} + +function formatElapsed(totalSeconds: number): string { + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +} + +export function EnvironmentSetup({ + initialStatus, + initialStartedAt, +}: EnvironmentSetupProps) { + const [status, setStatus] = useState(initialStatus); + const [progress, setProgress] = useState( + initialStatus === "provisioning" && initialStartedAt + ? progressForStage("creating_access", initialStartedAt) + : undefined, + ); + const [error, setError] = useState(); + const [pollWarning, setPollWarning] = useState(); + const [submitting, setSubmitting] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + + useEffect(() => { + if (status !== "provisioning" || !progress) return; + const startedAt = Date.parse(progress.startedAt); + const interval = window.setInterval(() => { + setElapsedSeconds( + Number.isFinite(startedAt) + ? Math.max(0, Math.floor((Date.now() - startedAt) / 1_000)) + : 0, + ); + }, 1_000); + return () => window.clearInterval(interval); + }, [progress, status]); + + useEffect(() => { + if (status !== "provisioning") return; + + let cancelled = false; + let nextPoll: number | undefined; + async function refreshProgress() { + try { + const response = await fetch("/api/environment", { cache: "no-store" }); + const parsed = environmentResponseSchema.safeParse( + await response.json().catch(() => null), + ); + if (!response.ok || !parsed.success) { + throw new Error("Status check failed"); + } + if (cancelled) return; + setPollWarning(undefined); + + if (parsed.data.status === "active") { + window.location.replace("/"); + return; + } + if (parsed.data.status === "failed") { + setStatus("failed"); + return; + } + if (parsed.data.status === "provisioning" && parsed.data.progress) { + setProgress(parsed.data.progress); + } + } catch { + if (!cancelled) { + setPollWarning( + "The latest status check was missed. Retrying automatically…", + ); + } + } + + if (!cancelled) { + nextPoll = window.setTimeout(refreshProgress, 3_000); + } + } + + void refreshProgress(); + return () => { + cancelled = true; + if (nextPoll !== undefined) window.clearTimeout(nextPoll); + }; + }, [status]); + + async function createEnvironment() { + setSubmitting(true); + setError(undefined); + try { + const response = await fetch("/api/environment", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const body = await response.json().catch(() => null); + if (!response.ok) { + const message = + body && typeof body.error === "string" + ? body.error + : "Environment creation could not start"; + throw new Error(message); + } + const parsed = environmentResponseSchema.safeParse(body); + if (!parsed.success || parsed.data.status !== "provisioning") { + throw new Error("Environment creation returned an invalid status"); + } + setProgress( + parsed.data.progress ?? + progressForStage("confirming_access", new Date().toISOString()), + ); + setElapsedSeconds(0); + setStatus("provisioning"); + } catch (requestError) { + setError( + requestError instanceof Error + ? requestError.message + : "Environment creation could not start", + ); + } finally { + setSubmitting(false); + } + } + + const isProvisioning = status === "provisioning"; + const currentStepIndex = progress + ? provisioningSteps.findIndex((step) => step.id === progress.stage) + : -1; + const currentStep = + currentStepIndex >= 0 ? provisioningSteps[currentStepIndex] : undefined; + + return ( +
+
+
+
+ {isProvisioning ? ( + + ) : ( + + )} +
+
+

+ {isProvisioning + ? "Creating your environment" + : "Create your coding environment"} +

+

+ {isProvisioning + ? "AWS is creating one private, persistent runtime for your company sign-in." + : "Your assigned company sign-in allows one private, persistent runtime in this AWS account."} +

+
+
+ + {isProvisioning && progress && currentStep ? ( +
+
+
+ + Step {currentStepIndex + 1} of {provisioningSteps.length} + + + Elapsed {formatElapsed(elapsedSeconds)} + +
+
+
+
+
+ {currentStep.estimate} + AWS timing can vary +
+
+ +
    + {provisioningSteps.map((step, index) => { + const isComplete = index < currentStepIndex; + const isCurrent = index === currentStepIndex; + return ( +
  1. + {isComplete ? ( + + ) : isCurrent ? ( + + ) : ( + + )} +
    +

    + {step.label} +

    + {isCurrent ? ( +

    + {step.detail} +

    + ) : null} +
    +
  2. + ); + })} +
+
+ ) : null} + + {status === "failed" ? ( +
+ +

The last AWS setup did not finish. You can safely try again.

+
+ ) : null} + {error ? ( +
+ +

{error}

+
+ ) : null} + {pollWarning ? ( +

+ {pollWarning} +

+ ) : null} + + +
+
+ ); +} diff --git a/web/src/components/mobile-terminal.tsx b/web/src/components/mobile-terminal.tsx new file mode 100644 index 0000000..ca7a0b5 --- /dev/null +++ b/web/src/components/mobile-terminal.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { TerminalPane } from "@/components/terminal/terminal-pane"; +import { + TerminalTab, + MobileTerminalProps, + RECENT_CLOSED_TABS_LIMIT, + createTerminalTab, + loadStoredTerminalState, + useHydrated, +} from "@/components/terminal/terminal-shared"; + +function ReadyMobileTerminal({ storageKey }: { storageKey: string }) { + const [initialState] = useState(() => loadStoredTerminalState(storageKey)); + const [tabs, setTabs] = useState(initialState.tabs); + const [closedTabs, setClosedTabs] = useState( + initialState.closedTabs, + ); + const [activeTabId, setActiveTabId] = useState(initialState.activeTabId); + const [nextTabIndex, setNextTabIndex] = useState(initialState.nextTabIndex); + const [mountedTabIds, setMountedTabIds] = useState( + initialState.mountedTabIds, + ); + const [pendingCloseTabId, setPendingCloseTabId] = useState( + null, + ); + const [showNewTabChooser, setShowNewTabChooser] = useState(false); + const [renamingTabId, setRenamingTabId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const newTabDraftRef = useRef<{ + tabId: string; + previousActiveTabId: string; + } | null>(null); + const longPressTimeoutRef = useRef(null); + + const activeTab = useMemo( + () => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0], + [activeTabId, tabs], + ); + const mountedTabIdSet = useMemo( + () => new Set(mountedTabIds), + [mountedTabIds], + ); + const pendingCloseTab = useMemo( + () => tabs.find((tab) => tab.id === pendingCloseTabId) ?? null, + [pendingCloseTabId, tabs], + ); + const renamingTab = useMemo( + () => tabs.find((tab) => tab.id === renamingTabId) ?? null, + [renamingTabId, tabs], + ); + + useEffect(() => { + if (!activeTab) return; + + window.localStorage.setItem( + storageKey, + JSON.stringify({ + activeTabId: activeTab.id, + closedTabs, + nextTabIndex, + tabs, + }), + ); + }, [activeTab, closedTabs, nextTabIndex, storageKey, tabs]); + + const createFreshTab = useCallback(() => { + const nextTab = createTerminalTab(nextTabIndex); + setTabs([...tabs, nextTab]); + setNextTabIndex(nextTabIndex + 1); + setMountedTabIds((currentIds) => [...currentIds, nextTab.id]); + newTabDraftRef.current = { + tabId: nextTab.id, + previousActiveTabId: activeTab.id, + }; + setActiveTabId(nextTab.id); + setShowNewTabChooser(false); + setRenamingTabId(nextTab.id); + setRenameValue(nextTab.label); + }, [activeTab.id, nextTabIndex, tabs]); + + const addTab = useCallback(() => { + if (closedTabs.length > 0) { + setShowNewTabChooser(true); + return; + } + + createFreshTab(); + }, [closedTabs.length, createFreshTab]); + + const reopenClosedTab = useCallback( + (tabId: string) => { + const tabToReopen = closedTabs.find((tab) => tab.id === tabId); + if (!tabToReopen) return; + + setClosedTabs(closedTabs.filter((tab) => tab.id !== tabId)); + setTabs([...tabs, tabToReopen]); + setMountedTabIds((currentIds) => + currentIds.includes(tabId) ? currentIds : [...currentIds, tabId], + ); + setActiveTabId(tabId); + setShowNewTabChooser(false); + }, + [closedTabs, tabs], + ); + + const selectTab = useCallback((tabId: string) => { + setMountedTabIds((currentIds) => + currentIds.includes(tabId) ? currentIds : [...currentIds, tabId], + ); + setActiveTabId(tabId); + }, []); + + const closeTab = useCallback( + (tabId: string) => { + if (tabs.length === 1) return; + + const closingTab = tabs.find((tab) => tab.id === tabId); + if (!closingTab) return; + + const closingIndex = tabs.findIndex((tab) => tab.id === tabId); + const nextTabs = tabs.filter((tab) => tab.id !== tabId); + const nextActiveIndex = Math.max(0, closingIndex - 1); + const nextActiveTabId = + activeTabId === tabId + ? (nextTabs[nextActiveIndex]?.id ?? nextTabs[0].id) + : activeTabId; + + setTabs(nextTabs); + setClosedTabs((currentClosedTabs) => + [ + closingTab, + ...currentClosedTabs.filter((tab) => tab.id !== closingTab.id), + ].slice(0, RECENT_CLOSED_TABS_LIMIT), + ); + setMountedTabIds((currentIds) => { + const nextMountedIds = currentIds.filter((id) => id !== tabId); + return nextMountedIds.includes(nextActiveTabId) + ? nextMountedIds + : [...nextMountedIds, nextActiveTabId]; + }); + setPendingCloseTabId(null); + + if (activeTabId === tabId) { + setActiveTabId(nextActiveTabId); + } + }, + [activeTabId, tabs], + ); + + const requestCloseTab = useCallback( + (tabId: string) => { + if (tabs.length === 1) return; + setPendingCloseTabId(tabId); + }, + [tabs.length], + ); + + const clearLongPressTimeout = useCallback(() => { + if (longPressTimeoutRef.current === null) return; + + window.clearTimeout(longPressTimeoutRef.current); + longPressTimeoutRef.current = null; + }, []); + + const beginRenameTab = useCallback( + (tab: TerminalTab) => { + clearLongPressTimeout(); + setRenamingTabId(tab.id); + setRenameValue(tab.label); + }, + [clearLongPressTimeout], + ); + + const handleTabPointerDown = useCallback( + (tab: TerminalTab) => { + clearLongPressTimeout(); + longPressTimeoutRef.current = window.setTimeout(() => { + beginRenameTab(tab); + }, 550); + }, + [beginRenameTab, clearLongPressTimeout], + ); + + const saveRename = useCallback(() => { + if (!renamingTab) return; + + const nextLabel = renameValue.trim().slice(0, 24); + setTabs((currentTabs) => + currentTabs.map((tab) => + tab.id === renamingTab.id + ? { ...tab, label: nextLabel || tab.tmuxSession } + : tab, + ), + ); + newTabDraftRef.current = null; + setRenamingTabId(null); + setRenameValue(""); + }, [renameValue, renamingTab]); + + const cancelRename = useCallback(() => { + const draft = newTabDraftRef.current; + if (draft?.tabId === renamingTabId) { + setTabs((currentTabs) => + currentTabs.filter((tab) => tab.id !== draft.tabId), + ); + setMountedTabIds((currentIds) => + currentIds.filter((id) => id !== draft.tabId), + ); + setActiveTabId(draft.previousActiveTabId); + newTabDraftRef.current = null; + } + + setRenamingTabId(null); + setRenameValue(""); + }, [renamingTabId]); + + useEffect(() => clearLongPressTimeout, [clearLongPressTimeout]); + + if (!activeTab) { + return null; + } + + return ( +
+
+
+ {tabs.map((tab) => { + const isActive = tab.id === activeTab.id; + const isMounted = mountedTabIdSet.has(tab.id); + + return ( +
+ +
+ ); + })} +
+ +
+ +
+ {tabs.map((tab) => { + if (!mountedTabIdSet.has(tab.id)) return null; + + const isActive = tab.id === activeTab.id; + + return ( +
+ +
+ ); + })} +
+ + {showNewTabChooser && ( +
+
+
+

+ Open Terminal Tab +

+

+ Create a fresh tmux session, or reopen one of your 20 most + recently closed tabs. +

+
+
+ + +
+

+ Recently Closed +

+ {closedTabs.map((tab) => ( + + ))} +
+
+
+ +
+
+
+ )} + + {renamingTab && ( +
+
+
+

+ Rename Tab +

+

+ Friendly labels help you remember what is happening in each tab. + The tmux session stays `{renamingTab.tmuxSession}`. +

+ setRenameValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + saveRename(); + } + if (event.key === "Escape") { + cancelRename(); + } + }} + autoFocus + maxLength={24} + className={cn( + "mt-2 w-full rounded-lg border border-input bg-background px-3 py-2", + "text-sm text-foreground placeholder:text-muted-foreground", + "focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30", + )} + /> +
+
+ + +
+
+
+ )} + + {pendingCloseTab && ( +
+
+
+

+ Close {pendingCloseTab.label}? +

+

+ This closes the browser connection for this tab. The tmux + session `{pendingCloseTab.tmuxSession}` will keep running on the + AWS runtime and can be reopened from the plus button. +

+
+
+ + +
+
+
+ )} +
+ ); +} + +export function MobileTerminal({ storageScope }: MobileTerminalProps) { + const hydrated = useHydrated(); + const storageKey = `mobile-terminal-tabs:${storageScope}`; + if (!hydrated) return
; + return ; +} diff --git a/web/src/components/oauth-callback-action.tsx b/web/src/components/oauth-callback-action.tsx new file mode 100644 index 0000000..87dcfe2 --- /dev/null +++ b/web/src/components/oauth-callback-action.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Link2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type OAuthStatus = { kind: "idle" | "success" | "error"; message: string }; + +export function OAuthCallbackAction() { + const [open, setOpen] = useState(false); + const [callbackUrl, setCallbackUrl] = useState(""); + const [status, setStatus] = useState({ + kind: "idle", + message: "", + }); + const [submitting, setSubmitting] = useState(false); + const textareaRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const focusId = requestAnimationFrame(() => { + textareaRef.current?.focus(); + }); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + setStatus({ kind: "idle", message: "" }); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + cancelAnimationFrame(focusId); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + const submitOAuthCallback = useCallback(async () => { + const trimmedCallbackUrl = callbackUrl.trim(); + if (!trimmedCallbackUrl) { + setStatus({ + kind: "error", + message: "Paste the failed localhost callback URL first.", + }); + return; + } + + setSubmitting(true); + setStatus({ kind: "idle", message: "" }); + + try { + const response = await fetch("/api/oauth/loopback", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ callbackUrl: trimmedCallbackUrl }), + }); + const body = await response + .json() + .catch(() => ({ error: "Unknown error" })); + if (!response.ok) { + throw new Error(body.error ?? `HTTP ${response.status}`); + } + + setCallbackUrl(""); + setStatus({ + kind: "success", + message: + "Callback delivered. Return to the terminal to finish sign-in.", + }); + } catch (error) { + setStatus({ + kind: "error", + message: + error instanceof Error + ? error.message + : "Failed to deliver OAuth callback.", + }); + } finally { + setSubmitting(false); + } + }, [callbackUrl]); + + const close = useCallback(() => { + setOpen(false); + setStatus({ kind: "idle", message: "" }); + }, []); + + return ( + <> + + + {open && ( +
+
+
+

+ Complete remote login +

+

+ Seeing 127.0.0.1 refused to connect is + expected: the login listener is inside your private runtime, not + on this device. +

+
    +
  1. + Copy the complete URL from the failed page's address bar. +
  2. +
  3. Return to this AgentFormation tab and paste it below.
  4. +
  5. Send it while the remote tool is still waiting.
  6. +
+
+ +